From 70f41713f4f17e6599ffc48890a896d59036c954 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:41:13 +0800 Subject: [PATCH 01/18] test: remove redundant config snapshots Why: the deleted snapshots repeated coverage already provided by the end-to-end config editing tests and turned unrelated metadata changes into hundreds of lines of review noise. Keeping only the observable path-editing assertions makes this consumer scenario easier to understand and maintain. Signed-off-by: tison --- tests/integration/Cargo.toml | 1 - tests/integration/tests/configenv.rs | 208 --- .../configenv__snapshots_config_shape.snap | 1149 ----------------- .../configenv__snapshots_env_options.snap | 280 ---- 4 files changed, 1638 deletions(-) delete mode 100644 tests/integration/tests/snapshots/configenv__snapshots_config_shape.snap delete mode 100644 tests/integration/tests/snapshots/configenv__snapshots_env_options.snap 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..a43ea62 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"); 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, - }, -] From eba2f69936ddee5b2c6278ab8a619c2eae0c5074 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:42:03 +0800 Subject: [PATCH 02/18] test: replace derive snapshots with assertions Why: full Debug snapshots locked the derive tests to incidental field ordering and formatting while hiding which Serde contract each test protected. Focused assertions make regressions in names, defaults, tagging, bounds, and recursion visible without penalizing unrelated metadata additions. Signed-off-by: tison --- Cargo.lock | 2 - tests/derive/Cargo.toml | 1 - tests/derive/tests/derive.rs | 102 ++++++-- ...apshots_conversion_based_opaque_shape.snap | 31 --- ..._tagged_enum_shape_from_variant_attrs.snap | 110 --------- ...ata_generic_field_without_shape_bound.snap | 51 ---- ...sive_type_reusing_the_same_definition.snap | 57 ----- ...ped_generic_field_without_shape_bound.snap | 49 ---- ..._shape_from_container_and_field_attrs.snap | 230 ------------------ ...e__snapshots_transparent_struct_shape.snap | 51 ---- 10 files changed, 84 insertions(+), 600 deletions(-) delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_conversion_based_opaque_shape.snap delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_internally_tagged_enum_shape_from_variant_attrs.snap delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_phantom_data_generic_field_without_shape_bound.snap delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_recursive_type_reusing_the_same_definition.snap delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_skipped_generic_field_without_shape_bound.snap delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_struct_shape_from_container_and_field_attrs.snap delete mode 100644 tests/derive/tests/snapshots/derive__snapshots_transparent_struct_shape.snap diff --git a/Cargo.lock b/Cargo.lock index 75f02d7..e6e8f18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,6 @@ dependencies = [ name = "serde-shape-test-derive" version = "0.0.0" dependencies = [ - "insta", "serde", "serde-shape", "serde_json", @@ -324,7 +323,6 @@ dependencies = [ name = "serde-shape-test-integration" version = "0.0.0" dependencies = [ - "insta", "serde", "serde-shape", "serde_test", 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/derive.rs b/tests/derive/tests/derive.rs index 8075bc1..7b91a23 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -14,6 +14,7 @@ #![allow(dead_code)] +use renamed_shape::DefaultShape; use renamed_shape::DeserializeDefinitionKind; use renamed_shape::DeserializeShape; use renamed_shape::DeserializeVariantContent; @@ -24,6 +25,7 @@ use renamed_shape::SerializeDefinitionKind; use renamed_shape::SerializeShape; use renamed_shape::SerializeVariantContent; use renamed_shape::ShapeRef; +use renamed_shape::Tagging; #[derive(DeserializeShape)] #[serde( @@ -171,38 +173,102 @@ fn default_retries() -> u8 { } #[test] -fn snapshots_struct_shape_from_container_and_field_attrs() { - insta::assert_debug_snapshot!(Config::deserialize_shape()); -} +fn exposes_deserialize_container_attributes() { + let graph = Config::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("root shape should be a definition"); + }; + let definition = graph.definition(id).expect("definition exists"); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { + panic!("definition should be a struct"); + }; -#[test] -fn snapshots_internally_tagged_enum_shape_from_variant_attrs() { - insta::assert_debug_snapshot!(Storage::deserialize_shape()); -} + assert_eq!(shape.attributes.default, DefaultShape::Default); + assert!(shape.attributes.deny_unknown_fields); + assert_eq!(shape.attributes.expecting, Some("config object")); -#[test] -fn snapshots_transparent_struct_shape() { - insta::assert_debug_snapshot!(UserId::deserialize_shape()); + 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_conversion_based_opaque_shape() { - insta::assert_debug_snapshot!(FromString::deserialize_shape()); +fn exposes_deserialize_enum_attributes() { + let graph = Storage::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("root shape should be a definition"); + }; + let definition = graph.definition(id).expect("definition exists"); + 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_skipped_generic_field_without_shape_bound() { - insta::assert_debug_snapshot!(SkipsGeneric::::deserialize_shape()); +fn exposes_transparent_and_conversion_boundaries() { + let transparent = UserId::deserialize_shape(); + let ShapeRef::Definition(id) = transparent.root else { + panic!("transparent root should be a definition"); + }; + let DeserializeDefinitionKind::Struct(shape) = &transparent.definition(id).unwrap().kind else { + panic!("transparent definition should be a struct"); + }; + assert!(shape.attributes.transparent); + assert_eq!( + shape.fields[0].wire_shape, + FieldWireShape::Inline(ShapeRef::U64) + ); + + let converted = FromString::deserialize_shape(); + let ShapeRef::Definition(id) = converted.root else { + panic!("converted root should be a definition"); + }; + let DeserializeDefinitionKind::Opaque(opaque) = &converted.definition(id).unwrap().kind else { + panic!("converted definition should be opaque"); + }; + assert_eq!(opaque.reason, OpaqueReason::FromType); + assert_eq!(opaque.detail, Some("String")); } #[test] -fn snapshots_phantom_data_generic_field_without_shape_bound() { - insta::assert_debug_snapshot!(Marker::::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 snapshots_recursive_type_reusing_the_same_definition() { - insta::assert_debug_snapshot!(Recursive::deserialize_shape()); +fn reuses_recursive_definition() { + let graph = Recursive::deserialize_shape(); + let ShapeRef::Definition(id) = graph.root else { + panic!("recursive root should be a definition"); + }; + 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] 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, - }, - }, - ), - }, - ], -} From 3f138d0ed6877794b288ccb0889aaea40a7a43eb Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:42:42 +0800 Subject: [PATCH 03/18] fix: follow Cow serialization bounds Why: Serde serializes Cow through its borrowed target but deserializes into its owned target. Delegating both directions to one side reports the wrong shape and can require a Shape bound that Serde itself does not require, so each direction must follow the corresponding Serde contract. Signed-off-by: tison --- serde-shape/src/impls/wrapper.rs | 31 ++++++++++++--------- serde-shape/src/tests.rs | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 12 deletions(-) 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/tests.rs b/serde-shape/src/tests.rs index b975949..71f0531 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()); @@ -310,6 +344,18 @@ fn maps_common_core_and_alloc_shapes() { ); } +#[test] +fn follows_cow_directional_serde_bounds() { + assert_eq!( + SerializeShapeGraph::for_type::>().root, + ShapeRef::U8 + ); + assert_eq!( + DeserializeShapeGraph::for_type::>().root, + ShapeRef::String + ); +} + #[cfg(feature = "std")] #[test] fn maps_common_std_shapes() { From c34841f37b4f0f7b3b66d30521a105f5528bdd50 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:44:11 +0800 Subject: [PATCH 04/18] fix: reflect Serde conversion types Why: the wire representation of Serde from, try_from, and into containers is already defined by the conversion type. Treating it as opaque discards exact information that documentation and configuration consumers need, so the derive now reuses the proxy type shape directly. Signed-off-by: tison --- serde-shape-derive/src/lib.rs | 85 ++++++++++++++++------------------- serde-shape/src/lib.rs | 10 +---- tests/derive/tests/derive.rs | 30 ++++++++----- 3 files changed, 61 insertions(+), 64 deletions(-) diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index 72fa3f3..c0e53be 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -137,7 +137,14 @@ fn parse_container<'a>(input: &'a DeriveInput, derive: Derive) -> syn::Result) { - if container.attrs.type_into().is_some() || container.attrs.remote().is_some() { + if container.attrs.remote().is_some() { + return; + } + if let Some(ty) = container.attrs.type_into() { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::SerializeShape)); return; } @@ -174,10 +181,18 @@ fn add_serialize_shape_bounds(generics: &mut syn::Generics, container: &ast::Con } 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() + if container.attrs.remote().is_some() { + return; + } + if let Some(ty) = container + .attrs + .type_from() + .or_else(|| container.attrs.type_try_from()) { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); return; } @@ -404,6 +419,10 @@ fn push_bound_type(field_bound_types: &mut Vec, ty: Type) { } fn serialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { + if let Some(ty) = container.attrs.type_into() { + return quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(context)); + } + let name = lit(container.attrs.name().serialize_name()); let kind = serialize_definition_kind(container); @@ -421,6 +440,14 @@ fn serialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { } fn deserialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { + if let Some(ty) = container + .attrs + .type_from() + .or_else(|| container.attrs.type_try_from()) + { + return quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(context)); + } + let name = lit(container.attrs.name().deserialize_name()); let kind = deserialize_definition_kind(container); @@ -438,11 +465,9 @@ fn deserialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { } fn serialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { - if let Some(ty) = container.attrs.type_into() { - return serialize_opaque_definition("IntoType", ty); - } if let Some(path) = container.attrs.remote() { - return serialize_opaque_definition("Remote", path); + let opaque = remote_opaque_shape(path); + return quote!(__serde_shape::SerializeDefinitionKind::Opaque(#opaque)); } let attributes = serialize_container_attributes(&container.attrs); @@ -473,14 +498,9 @@ 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); - } if let Some(path) = container.attrs.remote() { - return deserialize_opaque_definition("Remote", path); + let opaque = remote_opaque_shape(path); + return quote!(__serde_shape::DeserializeDefinitionKind::Opaque(#opaque)); } let attributes = deserialize_container_attributes(&container.attrs); @@ -510,35 +530,18 @@ fn deserialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { } } -fn serialize_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::SerializeDefinitionKind::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), - }) - } -} - -fn deserialize_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::DeserializeDefinitionKind::Opaque(__serde_shape::OpaqueShape { - type_name: ::core::any::type_name::(), - reason: #reason, - detail: ::core::option::Option::Some(#detail), - }) + } } } @@ -798,16 +801,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/src/lib.rs b/serde-shape/src/lib.rs index a47e265..2e8accc 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -151,8 +151,8 @@ //! 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. @@ -961,12 +961,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/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 7b91a23..7f22314 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -75,6 +75,18 @@ 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(DeserializeShape)] struct SkipsGeneric { #[serde(skip)] @@ -219,7 +231,7 @@ fn exposes_deserialize_enum_attributes() { } #[test] -fn exposes_transparent_and_conversion_boundaries() { +fn exposes_transparent_shape() { let transparent = UserId::deserialize_shape(); let ShapeRef::Definition(id) = transparent.root else { panic!("transparent root should be a definition"); @@ -232,16 +244,14 @@ fn exposes_transparent_and_conversion_boundaries() { shape.fields[0].wire_shape, FieldWireShape::Inline(ShapeRef::U64) ); +} - let converted = FromString::deserialize_shape(); - let ShapeRef::Definition(id) = converted.root else { - panic!("converted root should be a definition"); - }; - let DeserializeDefinitionKind::Opaque(opaque) = &converted.definition(id).unwrap().kind else { - panic!("converted definition should be opaque"); - }; - assert_eq!(opaque.reason, OpaqueReason::FromType); - assert_eq!(opaque.detail, Some("String")); +#[test] +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] From 9c127178e753981d46b8a649b89d2c16991acf31 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:49:12 +0800 Subject: [PATCH 05/18] feat: support explicit shape representations Why: custom Serde functions and foreign types are necessarily opaque to the derive, and downstream crates cannot add Shape implementations to foreign types because of orphan rules. Directional serde_shape overrides give callers a local, explicit way to state the real representation without moving consumer policy into serde-shape. Signed-off-by: tison --- README.md | 23 +++ serde-shape-derive/src/lib.rs | 283 +++++++++++++++++++-------- serde-shape-derive/src/shape_attr.rs | 104 ++++++++++ serde-shape/src/lib.rs | 10 + tests/derive/tests/derive.rs | 57 ++++++ 5 files changed, 394 insertions(+), 83 deletions(-) create mode 100644 serde-shape-derive/src/shape_attr.rs diff --git a/README.md b/README.md index fe56db0..ca58633 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,29 @@ 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. +## Custom representations + +Custom Serde functions and foreign types do not expose enough information for `serde-shape` to infer their wire representation. Declare the representation explicitly with `#[serde_shape(with = "Type")]`, or use `serialize_as` and `deserialize_as` when the two directions differ: + +```rust +use serde_shape::{DeserializeShape, SerializeShape}; + +struct ForeignDuration; +struct ForeignUrl; + +#[derive(SerializeShape, DeserializeShape)] +struct Config { + #[serde(with = "duration_format")] + #[serde_shape(with = "String")] + timeout: ForeignDuration, + + #[serde_shape(serialize_as = "String", deserialize_as = "String")] + endpoint: ForeignUrl, +} +``` + +The replacement type must implement the corresponding shape trait. An override is an assertion about the custom Serde behavior; `serde-shape` cannot verify that the declared type matches the serializer or deserializer implementation. + ## Feature flags `serde-shape` enables no features by default. diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index c0e53be..8e39891 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -39,8 +39,12 @@ use syn::TypeParamBound; use syn::parse_macro_input; use syn::parse_quote; +mod shape_attr; + +use shape_attr::ShapeAttrs; + /// 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 +55,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 +68,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 +94,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,27 +144,67 @@ fn parse_container<'a>(input: &'a DeriveInput, derive: Derive) -> syn::Result) { - if container.attrs.remote().is_some() { - return; - } - if let Some(ty) = container.attrs.type_into() { - generics - .make_where_clause() - .predicates - .push(parse_quote!(#ty: __serde_shape::SerializeShape)); - 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 type overrides 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 let Some(ty) = shape_attrs.serialize_as() { + if type_uses_params(ty, &type_params) { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::SerializeShape)); + } + 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 { @@ -167,7 +215,7 @@ fn add_serialize_shape_bounds(generics: &mut syn::Generics, container: &ast::Con &variant.fields, &type_params, &mut field_bound_types, - ); + )?; } } } @@ -178,33 +226,49 @@ 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<'_>) { +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 let Some(ty) = shape_attrs.deserialize_as() { + if type_uses_params(ty, &type_params) { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); + } + return Ok(()); + } if container.attrs.remote().is_some() { - return; + return Ok(()); } if let Some(ty) = container .attrs .type_from() .or_else(|| container.attrs.type_try_from()) { - generics - .make_where_clause() - .predicates - .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); - return; + if type_uses_params(ty, &type_params) { + generics + .make_where_clause() + .predicates + .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); + } + return Ok(()); } - let type_params: BTreeSet<_> = generics - .type_params() - .map(|param| param.ident.to_string()) - .collect(); 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 { @@ -216,7 +280,7 @@ fn add_deserialize_shape_bounds(generics: &mut syn::Generics, container: &ast::C &variant.fields, &type_params, &mut field_bound_types, - ); + )?; } } } @@ -227,40 +291,49 @@ 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 let Some(ty) = shape_attrs.serialize_as() { + if type_uses_params(ty, type_params) { + push_bound_type(field_bound_types, ty.clone()); + } + } else if 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 let Some(ty) = shape_attrs.deserialize_as() { + if type_uses_params(ty, type_params) { + push_bound_type(field_bound_types, ty.clone()); + } + } else if 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( @@ -345,6 +418,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, @@ -418,15 +497,21 @@ 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(ty) = shape_attrs.serialize_as() { + return Ok(quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(context))); + } if let Some(ty) = container.attrs.type_into() { - return quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(context)); + 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 kind = serialize_definition_kind(container)?; - quote! { + Ok(quote! { context.define_named_type( __serde_shape::SerializeTypeName { rust_name: ::core::any::type_name::(), @@ -436,22 +521,28 @@ fn serialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { #kind }, ) - } + }) } -fn deserialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { +fn deserialize_shape_body( + container: &ast::Container<'_>, + shape_attrs: &ShapeAttrs, +) -> syn::Result { + if let Some(ty) = shape_attrs.deserialize_as() { + return Ok(quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(context))); + } if let Some(ty) = container .attrs .type_from() .or_else(|| container.attrs.type_try_from()) { - return quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(context)); + 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 kind = deserialize_definition_kind(container)?; - quote! { + Ok(quote! { context.define_named_type( __serde_shape::DeserializeTypeName { rust_name: ::core::any::type_name::(), @@ -461,20 +552,23 @@ fn deserialize_shape_body(container: &ast::Container<'_>) -> TokenStream2 { #kind }, ) - } + }) } -fn serialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { +fn serialize_definition_kind(container: &ast::Container<'_>) -> syn::Result { if let Some(path) = container.attrs.remote() { let opaque = remote_opaque_shape(path); - return quote!(__serde_shape::SerializeDefinitionKind::Opaque(#opaque)); + 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, @@ -485,7 +579,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, @@ -494,20 +591,23 @@ fn serialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { }) } } - } + }) } -fn deserialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { +fn deserialize_definition_kind(container: &ast::Container<'_>) -> syn::Result { if let Some(path) = container.attrs.remote() { let opaque = remote_opaque_shape(path); - return quote!(__serde_shape::DeserializeDefinitionKind::Opaque(#opaque)); + 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, @@ -518,7 +618,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, @@ -527,7 +630,7 @@ fn deserialize_definition_kind(container: &ast::Container<'_>) -> TokenStream2 { }) } } - } + }) } fn remote_opaque_shape(detail: T) -> TokenStream2 @@ -583,7 +686,7 @@ 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 style = fields_style(variant.style); @@ -601,7 +704,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),*], @@ -609,7 +716,7 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::SerializeVariantShape { rust_name: #rust_name, name: #name, @@ -617,10 +724,10 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { 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()); @@ -640,7 +747,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),*], @@ -648,7 +759,7 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::DeserializeVariantShape { rust_name: #rust_name, name: #name, @@ -658,10 +769,11 @@ fn deserialize_variant_shape(variant: &ast::Variant<'_>) -> TokenStream2 { 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 skip = field.attrs.skip_serializing(); @@ -672,7 +784,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(ty) = shape_attrs.serialize_as() { + quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(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 { @@ -694,17 +808,18 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::SerializeFieldShape { member: #member, name: #name, 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()); @@ -716,7 +831,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(ty) = shape_attrs.deserialize_as() { + quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(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 { @@ -738,7 +855,7 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { } }; - quote! { + Ok(quote! { __serde_shape::DeserializeFieldShape { member: #member, name: #name, @@ -746,7 +863,7 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> TokenStream2 { wire_shape: #wire_shape, default: #default, } - } + }) } fn field_member(member: &Member) -> TokenStream2 { diff --git a/serde-shape-derive/src/shape_attr.rs b/serde-shape-derive/src/shape_attr.rs new file mode 100644 index 0000000..3235941 --- /dev/null +++ b/serde-shape-derive/src/shape_attr.rs @@ -0,0 +1,104 @@ +// 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::LitStr; +use syn::Type; +use syn::meta::ParseNestedMeta; +use syn::spanned::Spanned; + +#[derive(Default)] +pub struct ShapeAttrs { + serialize_as: Option<(Type, Span)>, + deserialize_as: Option<(Type, Span)>, + with: Option<(Type, 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("with") { + set_once(&mut parsed.with, parse_type(&meta)?, meta.path.span()) + } else if meta.path.is_ident("serialize_as") { + set_once( + &mut parsed.serialize_as, + parse_type(&meta)?, + meta.path.span(), + ) + } else if meta.path.is_ident("deserialize_as") { + set_once( + &mut parsed.deserialize_as, + parse_type(&meta)?, + meta.path.span(), + ) + } else { + Err(meta.error( + "unknown serde_shape attribute; expected `with`, `serialize_as`, or `deserialize_as`", + )) + } + })?; + } + + if let Some((_, span)) = &parsed.with { + if parsed.serialize_as.is_some() || parsed.deserialize_as.is_some() { + return Err(syn::Error::new( + *span, + "`with` cannot be combined with `serialize_as` or `deserialize_as`", + )); + } + } + + Ok(parsed) + } + + pub fn serialize_as(&self) -> Option<&Type> { + self.serialize_as + .as_ref() + .or(self.with.as_ref()) + .map(|(ty, _)| ty) + } + + pub fn deserialize_as(&self) -> Option<&Type> { + self.deserialize_as + .as_ref() + .or(self.with.as_ref()) + .map(|(ty, _)| ty) + } + + pub fn is_empty(&self) -> bool { + self.serialize_as.is_none() && self.deserialize_as.is_none() && self.with.is_none() + } +} + +fn parse_type(meta: &ParseNestedMeta<'_>) -> syn::Result { + let value = meta.value()?; + let value: LitStr = value.parse()?; + value.parse() +} + +fn set_once(slot: &mut Option<(Type, Span)>, ty: Type, span: Span) -> syn::Result<()> { + if slot.is_some() { + return Err(syn::Error::new(span, "duplicate serde_shape attribute")); + } + *slot = Some((ty, span)); + Ok(()) +} diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 2e8accc..90afedf 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -157,6 +157,10 @@ //! transparent fields, and omitted fields. Custom serializer/deserializer boundaries use //! [`ShapeRef::Opaque`] and remain composable with those field positions. //! +//! Use `#[serde_shape(with = "Type")]` to declare the representation of a container or field that +//! cannot be inferred. `serialize_as` and `deserialize_as` provide direction-specific overrides. +//! The replacement type must implement the corresponding shape trait. +//! //! # Manual implementations //! //! Implement [`trait@SerializeShape`] or [`trait@DeserializeShape`] manually when a type's Serde @@ -216,6 +220,9 @@ 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_as = "Type")]` on a container or field to override an opaque +/// or foreign representation. `#[serde_shape(with = "Type")]` applies to both directions. +/// /// # Example /// /// ```rust @@ -255,6 +262,9 @@ 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_as = "Type")]` on a container or field to override an opaque +/// or foreign representation. `#[serde_shape(with = "Type")]` applies to both directions. +/// /// # Example /// /// ```rust diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 7f22314..c39dc79 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -87,6 +87,19 @@ struct IntoString(String); #[serde(from = "T")] struct FromGeneric(T); +#[derive(SerializeShape, DeserializeShape)] +#[serde_shape(serialize_as = "u16", deserialize_as = "String")] +struct ContainerShapeOverride(NotShape); + +#[derive(SerializeShape, DeserializeShape)] +struct FieldShapeOverrides { + #[serde(with = "custom_representation")] + #[serde_shape(with = "String")] + custom: NotShape, + #[serde_shape(serialize_as = "u8", deserialize_as = "bool")] + directional: NotShape, +} + #[derive(DeserializeShape)] struct SkipsGeneric { #[serde(skip)] @@ -254,6 +267,50 @@ fn follows_serde_conversion_shapes() { assert_eq!(FromGeneric::::deserialize_shape().root, ShapeRef::U8); } +#[test] +fn applies_container_and_field_shape_overrides() { + assert_eq!( + ContainerShapeOverride::serialize_shape().root, + ShapeRef::U16 + ); + assert_eq!( + ContainerShapeOverride::deserialize_shape().root, + ShapeRef::String + ); + + let serialize = FieldShapeOverrides::serialize_shape(); + let ShapeRef::Definition(id) = serialize.root else { + panic!("serialize root should be a definition"); + }; + let SerializeDefinitionKind::Struct(shape) = &serialize.definition(id).unwrap().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 deserialize = FieldShapeOverrides::deserialize_shape(); + let ShapeRef::Definition(id) = deserialize.root else { + panic!("deserialize root should be a definition"); + }; + let DeserializeDefinitionKind::Struct(shape) = &deserialize.definition(id).unwrap().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 omits_shape_bounds_for_skipped_and_marker_fields() { assert_eq!( From 11b275f94993bc92c8749564a20d68aad805f6a6 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:51:26 +0800 Subject: [PATCH 06/18] test: replace no_std snapshots with assertions Why: the no_std snapshots tested Debug output rather than the properties that matter on alloc-only targets. Direct assertions retain coverage for derive expansion, nested containers, and recursive definitions while removing the final snapshot-only dependency from the workspace. Signed-off-by: tison --- Cargo.lock | 119 ------------------ Cargo.toml | 1 - tests/no_std/Cargo.toml | 3 - tests/no_std/tests/shapes.rs | 77 ++++++++++++ tests/no_std/tests/snapshots.rs | 27 ---- ...shots_no_std_config_deserialize_shape.snap | 87 ------------- ...apshots_no_std_config_serialize_shape.snap | 75 ----------- 7 files changed, 77 insertions(+), 312 deletions(-) create mode 100644 tests/no_std/tests/shapes.rs delete mode 100644 tests/no_std/tests/snapshots.rs delete mode 100644 tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_deserialize_shape.snap delete mode 100644 tests/no_std/tests/snapshots/snapshots__snapshots_no_std_config_serialize_shape.snap diff --git a/Cargo.lock b/Cargo.lock index e6e8f18..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" @@ -333,7 +234,6 @@ dependencies = [ name = "serde-shape-test-no-std" version = "0.0.0" dependencies = [ - "insta", "serde-shape", ] @@ -399,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" @@ -433,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/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..32ecebe --- /dev/null +++ b/tests/no_std/tests/shapes.rs @@ -0,0 +1,77 @@ +// 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"); + }; + 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"); + }; + 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, - }, - }, - ), - }, - ], -} From d881f729b064a761378a8731500f583e2ea177e2 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:53:44 +0800 Subject: [PATCH 07/18] feat: preserve Rust documentation in shapes Why: configuration references, CLI help, and diagnostics need the same user-facing descriptions that already live beside Rust fields and variants. Carrying doc comments in shape metadata avoids a second description registry and keeps generated documentation synchronized with the source type. Signed-off-by: tison --- serde-shape-derive/src/lib.rs | 23 +++++++++- serde-shape-derive/src/shape_attr.rs | 36 +++++++++++++++ serde-shape/src/impls/net.rs | 4 ++ serde-shape/src/impls/result.rs | 4 ++ serde-shape/src/impls/time.rs | 4 ++ serde-shape/src/lib.rs | 46 ++++++++++++++++++++ tests/derive/tests/derive.rs | 65 ++++++++++++++++++++++++++++ 7 files changed, 180 insertions(+), 2 deletions(-) diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index 8e39891..33fc23e 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -42,6 +42,7 @@ 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, serde_shape))] @@ -509,14 +510,17 @@ fn serialize_shape_body( } let name = lit(container.attrs.name().serialize_name()); + let description = description(&container.original.attrs); + let description = option_lit(description.as_deref()); let kind = serialize_definition_kind(container)?; Ok(quote! { - context.define_named_type( + context.define_named_type_with_description( __serde_shape::SerializeTypeName { rust_name: ::core::any::type_name::(), name: #name, }, + #description, |context| { #kind }, @@ -540,14 +544,17 @@ fn deserialize_shape_body( } let name = lit(container.attrs.name().deserialize_name()); + let description = description(&container.original.attrs); + let description = option_lit(description.as_deref()); let kind = deserialize_definition_kind(container)?; Ok(quote! { - context.define_named_type( + context.define_named_type_with_description( __serde_shape::DeserializeTypeName { rust_name: ::core::any::type_name::(), name: #name, }, + #description, |context| { #kind }, @@ -689,6 +696,8 @@ fn deserialize_container_attributes(attrs: &attr::Container) -> 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(); @@ -720,6 +729,7 @@ fn serialize_variant_shape(variant: &ast::Variant<'_>) -> syn::Result) -> syn::Result) -> syn::Result) -> 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(); @@ -812,6 +827,7 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> syn::Result { __serde_shape::SerializeFieldShape { member: #member, name: #name, + description: #description, wire_shape: #wire_shape, skip_if: #skip_if, } @@ -823,6 +839,8 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> syn::Result 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(); @@ -860,6 +878,7 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> syn::Result member: #member, name: #name, aliases: #aliases, + description: #description, wire_shape: #wire_shape, default: #default, } diff --git a/serde-shape-derive/src/shape_attr.rs b/serde-shape-derive/src/shape_attr.rs index 3235941..a026aac 100644 --- a/serde-shape-derive/src/shape_attr.rs +++ b/serde-shape-derive/src/shape_attr.rs @@ -14,6 +14,8 @@ use proc_macro2::Span; use syn::Attribute; +use syn::Expr; +use syn::Lit; use syn::LitStr; use syn::Type; use syn::meta::ParseNestedMeta; @@ -89,6 +91,40 @@ impl ShapeAttrs { } } +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_type(meta: &ParseNestedMeta<'_>) -> syn::Result { let value = meta.value()?; let value: LitStr = value.parse()?; 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/lib.rs b/serde-shape/src/lib.rs index 90afedf..96ec9d6 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -400,6 +400,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, { @@ -416,6 +432,7 @@ impl SerializeShapeContext { self.definitions[id.0] = Some(SerializeDefinitionShape { id, type_name, + description, kind, }); ShapeRef::Definition(id) @@ -442,6 +459,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, { @@ -458,6 +491,7 @@ impl DeserializeShapeContext { self.definitions[id.0] = Some(DeserializeDefinitionShape { id, type_name, + description, kind, }); ShapeRef::Definition(id) @@ -683,6 +717,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, } @@ -694,6 +730,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, } @@ -837,6 +875,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. @@ -852,6 +892,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. @@ -888,6 +930,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. @@ -917,6 +961,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. diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index c39dc79..4fa5e39 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -100,6 +100,20 @@ struct FieldShapeOverrides { 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)] @@ -311,6 +325,57 @@ fn applies_container_and_field_shape_overrides() { ); } +#[test] +fn preserves_rust_documentation() { + let serialize = DocumentedSetting::serialize_shape(); + let ShapeRef::Definition(id) = serialize.root else { + panic!("serialize root should be a definition"); + }; + let definition = serialize.definition(id).unwrap(); + 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 deserialize = DocumentedSetting::deserialize_shape(); + let ShapeRef::Definition(id) = deserialize.root else { + panic!("deserialize root should be a definition"); + }; + let definition = deserialize.definition(id).unwrap(); + 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 omits_shape_bounds_for_skipped_and_marker_fields() { assert_eq!( From a924e38b8c6adf9220d4998a243cb38d79ef9938 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:56:04 +0800 Subject: [PATCH 08/18] refactor: protect shape graph invariants Why: callers could mutate roots and definition lists or forge arbitrary ShapeId values, producing dangling graph references that failed only during later traversal. Read-only accessors and graph-issued identifiers make invalid states harder to construct while preserving zero-copy inspection. Signed-off-by: tison --- README.md | 4 +- serde-shape/src/lib.rs | 61 +++++++++++----- serde-shape/src/tests.rs | 100 ++++++++++++++------------- tests/derive/tests/derive.rs | 90 +++++++++++++----------- tests/derive/tests/serde_compat.rs | 16 ++--- tests/integration/tests/configenv.rs | 2 +- tests/no_std/tests/shapes.rs | 11 +-- 7 files changed, 161 insertions(+), 123 deletions(-) diff --git a/README.md b/README.md index ca58633..9f4b128 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,10 @@ 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"); diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index 96ec9d6..ac12fc1 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"); @@ -181,8 +181,8 @@ //! } //! //! assert_eq!( -//! ByteSize::deserialize_shape().root, -//! ShapeRef::union([ShapeRef::String, ShapeRef::U64]) +//! ByteSize::deserialize_shape().root(), +//! &ShapeRef::union([ShapeRef::String, ShapeRef::U64]) //! ); //! ``` //! @@ -240,10 +240,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"); @@ -281,10 +281,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"); @@ -333,9 +333,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 { @@ -352,6 +352,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) @@ -362,9 +372,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 { @@ -381,6 +391,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) @@ -507,7 +527,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)] diff --git a/serde-shape/src/tests.rs b/serde-shape/src/tests.rs index 71f0531..3941839 100644 --- a/serde-shape/src/tests.rs +++ b/serde-shape/src/tests.rs @@ -186,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 ); } @@ -204,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"); }; @@ -246,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"); @@ -265,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"); }; @@ -306,53 +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 + SerializeShapeGraph::for_type::>().root(), + &ShapeRef::U8 ); assert_eq!( - DeserializeShapeGraph::for_type::>().root, - ShapeRef::String + DeserializeShapeGraph::for_type::>().root(), + &ShapeRef::String ); } @@ -360,28 +362,28 @@ fn follows_cow_directional_serde_bounds() { #[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/tests/derive.rs b/tests/derive/tests/derive.rs index 4fa5e39..92dc1c3 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -214,10 +214,10 @@ fn default_retries() -> u8 { #[test] fn exposes_deserialize_container_attributes() { let graph = Config::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root else { + let ShapeRef::Definition(id) = graph.root() else { panic!("root shape should be a definition"); }; - let definition = graph.definition(id).expect("definition exists"); + let definition = graph.definition(*id).expect("definition exists"); let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -243,10 +243,10 @@ fn exposes_deserialize_container_attributes() { #[test] fn exposes_deserialize_enum_attributes() { let graph = Storage::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root else { + let ShapeRef::Definition(id) = graph.root() else { panic!("root shape should be a definition"); }; - let definition = graph.definition(id).expect("definition exists"); + let definition = graph.definition(*id).expect("definition exists"); let DeserializeDefinitionKind::Enum(shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -260,10 +260,11 @@ fn exposes_deserialize_enum_attributes() { #[test] fn exposes_transparent_shape() { let transparent = UserId::deserialize_shape(); - let ShapeRef::Definition(id) = transparent.root else { + let ShapeRef::Definition(id) = transparent.root() else { panic!("transparent root should be a definition"); }; - let DeserializeDefinitionKind::Struct(shape) = &transparent.definition(id).unwrap().kind else { + let DeserializeDefinitionKind::Struct(shape) = &transparent.definition(*id).unwrap().kind + else { panic!("transparent definition should be a struct"); }; assert!(shape.attributes.transparent); @@ -275,28 +276,28 @@ fn exposes_transparent_shape() { #[test] 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); + 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 applies_container_and_field_shape_overrides() { assert_eq!( - ContainerShapeOverride::serialize_shape().root, - ShapeRef::U16 + ContainerShapeOverride::serialize_shape().root(), + &ShapeRef::U16 ); assert_eq!( - ContainerShapeOverride::deserialize_shape().root, - ShapeRef::String + ContainerShapeOverride::deserialize_shape().root(), + &ShapeRef::String ); let serialize = FieldShapeOverrides::serialize_shape(); - let ShapeRef::Definition(id) = serialize.root else { + let ShapeRef::Definition(id) = serialize.root() else { panic!("serialize root should be a definition"); }; - let SerializeDefinitionKind::Struct(shape) = &serialize.definition(id).unwrap().kind else { + let SerializeDefinitionKind::Struct(shape) = &serialize.definition(*id).unwrap().kind else { panic!("serialize definition should be a struct"); }; assert_eq!( @@ -309,10 +310,11 @@ fn applies_container_and_field_shape_overrides() { ); let deserialize = FieldShapeOverrides::deserialize_shape(); - let ShapeRef::Definition(id) = deserialize.root else { + let ShapeRef::Definition(id) = deserialize.root() else { panic!("deserialize root should be a definition"); }; - let DeserializeDefinitionKind::Struct(shape) = &deserialize.definition(id).unwrap().kind else { + let DeserializeDefinitionKind::Struct(shape) = &deserialize.definition(*id).unwrap().kind + else { panic!("deserialize definition should be a struct"); }; assert_eq!( @@ -328,10 +330,10 @@ fn applies_container_and_field_shape_overrides() { #[test] fn preserves_rust_documentation() { let serialize = DocumentedSetting::serialize_shape(); - let ShapeRef::Definition(id) = serialize.root else { + let ShapeRef::Definition(id) = serialize.root() else { panic!("serialize root should be a definition"); }; - let definition = serialize.definition(id).unwrap(); + let definition = serialize.definition(*id).unwrap(); assert_eq!( definition.description, Some("Selects the retry policy.\n\nThis text is available to configuration tooling.") @@ -352,10 +354,10 @@ fn preserves_rust_documentation() { ); let deserialize = DocumentedSetting::deserialize_shape(); - let ShapeRef::Definition(id) = deserialize.root else { + let ShapeRef::Definition(id) = deserialize.root() else { panic!("deserialize root should be a definition"); }; - let definition = deserialize.definition(id).unwrap(); + let definition = deserialize.definition(*id).unwrap(); assert_eq!( definition.description, Some("Selects the retry policy.\n\nThis text is available to configuration tooling.") @@ -380,23 +382,27 @@ fn preserves_rust_documentation() { fn omits_shape_bounds_for_skipped_and_marker_fields() { assert_eq!( SkipsGeneric::::deserialize_shape() - .definitions + .definitions() .len(), 1 ); - assert_eq!(Marker::::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 { + 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!(graph.definitions().len(), 1); assert_eq!( shape.fields[0].wire_shape, FieldWireShape::Value(ShapeRef::Option(Box::new(ShapeRef::Definition(id)))) @@ -408,8 +414,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] @@ -417,17 +423,17 @@ 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 { + 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 = shape.definition(*id).expect("definition exists"); let DeserializeDefinitionKind::Struct(struct_shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -456,10 +462,10 @@ 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 { + 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 = shape.definition(*id).expect("definition exists"); let SerializeDefinitionKind::Struct(struct_shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -492,10 +498,10 @@ 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 { + 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 = shape.definition(*id).expect("definition exists"); let DeserializeDefinitionKind::Enum(enum_shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -531,10 +537,10 @@ 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 { + 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 = shape.definition(*id).expect("definition exists"); let SerializeDefinitionKind::Enum(enum_shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -573,15 +579,15 @@ fn derives_one_direction_without_requiring_the_other_direction() { let deserialize_shape = DeserializeOnly::::deserialize_shape(); assert!(matches!( - serialize_shape.definition(match serialize_shape.root { - renamed_shape::ShapeRef::Definition(id) => id, + 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 { .. }) )); assert!(matches!( - deserialize_shape.definition(match deserialize_shape.root { - renamed_shape::ShapeRef::Definition(id) => id, + 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 { .. }) diff --git a/tests/derive/tests/serde_compat.rs b/tests/derive/tests/serde_compat.rs index f2dba8f..0a92804 100644 --- a/tests/derive/tests/serde_compat.rs +++ b/tests/derive/tests/serde_compat.rs @@ -158,10 +158,10 @@ where T: SerializeShape, { let graph = T::serialize_shape(); - let ShapeRef::Definition(id) = graph.root else { + let ShapeRef::Definition(id) = graph.root() else { panic!("root shape should be a definition"); }; - let definition = graph.definition(id).expect("definition should exist"); + let definition = graph.definition(*id).expect("definition should exist"); let SerializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -173,10 +173,10 @@ where T: DeserializeShape, { let graph = T::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root else { + let ShapeRef::Definition(id) = graph.root() else { panic!("root shape should be a definition"); }; - let definition = graph.definition(id).expect("definition should exist"); + let definition = graph.definition(*id).expect("definition should exist"); let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -188,10 +188,10 @@ where T: SerializeShape, { let graph = T::serialize_shape(); - let ShapeRef::Definition(id) = graph.root else { + let ShapeRef::Definition(id) = graph.root() else { panic!("root shape should be a definition"); }; - let definition = graph.definition(id).expect("definition should exist"); + let definition = graph.definition(*id).expect("definition should exist"); let SerializeDefinitionKind::Enum(shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -207,10 +207,10 @@ where T: DeserializeShape, { let graph = T::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root else { + let ShapeRef::Definition(id) = graph.root() else { panic!("root shape should be a definition"); }; - let definition = graph.definition(id).expect("definition should exist"); + let definition = graph.definition(*id).expect("definition should exist"); let DeserializeDefinitionKind::Enum(shape) = &definition.kind else { panic!("definition should be an enum"); }; diff --git a/tests/integration/tests/configenv.rs b/tests/integration/tests/configenv.rs index a43ea62..cd3d338 100644 --- a/tests/integration/tests/configenv.rs +++ b/tests/integration/tests/configenv.rs @@ -169,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/no_std/tests/shapes.rs b/tests/no_std/tests/shapes.rs index 32ecebe..46182e5 100644 --- a/tests/no_std/tests/shapes.rs +++ b/tests/no_std/tests/shapes.rs @@ -23,10 +23,12 @@ 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 { + let ShapeRef::Definition(root_id) = graph.root() else { panic!("root shape should be a definition"); }; - assert_eq!(graph.definitions.len(), 1); + 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"); @@ -51,10 +53,11 @@ fn reflects_no_std_deserialization() { #[test] fn reflects_no_std_serialization() { let graph = NoStdConfig::serialize_shape(); - let ShapeRef::Definition(root_id) = graph.root else { + let ShapeRef::Definition(root_id) = graph.root() else { panic!("root shape should be a definition"); }; - assert_eq!(graph.definitions.len(), 1); + 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"); From c90811a3c32de7ac083c16ec88b7bd2d3f78a304 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:56:48 +0800 Subject: [PATCH 09/18] docs: describe shape construction accurately Why: the previous compile-time wording implied that complete graphs were constants and carried no runtime construction cost. Derive generates metadata code at compile time, but graph allocation happens when a shape method is called, so the package description must set the correct performance and ownership expectations. Signed-off-by: tison --- README.md | 2 +- serde-shape/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9f4b128..33cc7d4 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ [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. 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"] From 2b1647bc3ab7805949fdf58995282fff1925dd6a Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:57:23 +0800 Subject: [PATCH 10/18] docs: define shape model boundaries Why: users otherwise risk persisting graph-local IDs and Debug output as a schema format or assuming unions are specialized for one serializer. Stating the persistence, format-mode, and description contracts up front prevents consumers from building on guarantees the crate does not provide. Signed-off-by: tison --- README.md | 12 ++++++++++++ serde-shape/src/lib.rs | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 33cc7d4..71dbf3d 100644 --- a/README.md +++ b/README.md @@ -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, @@ -89,15 +91,19 @@ let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { }; 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. Declare the representation explicitly with `#[serde_shape(with = "Type")]`, or use `serialize_as` and `deserialize_as` when the two directions differ: @@ -121,6 +127,12 @@ struct Config { The replacement type must implement the corresponding shape trait. An override is an assertion about the custom Serde behavior; `serde-shape` cannot verify that the declared type 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. diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index ac12fc1..c57053f 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -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 //! @@ -161,6 +166,9 @@ //! cannot be inferred. `serialize_as` and `deserialize_as` provide direction-specific overrides. //! The replacement type must implement the corresponding shape trait. //! +//! 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 From e8b6a6e0efe66c46955e6589bd0d6b2368e96639 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:57:47 +0800 Subject: [PATCH 11/18] docs: add an unreleased changelog Why: this series contains intentional API migrations alongside fixes and additions, and reviewers or early adopters need one place to identify required call-site changes. The changelog records user-visible impact without mixing test and implementation detail into release notes. Signed-off-by: tison --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0c14c38 --- /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(with = "Type")]`, `serialize_as`, and `deserialize_as` overrides 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. From c5f408467f21e4fac0c5a7bcdf8b21a2d65dc9e6 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:58:11 +0800 Subject: [PATCH 12/18] docs: add contributor workflow guidance Why: the repository has distinct behavior boundaries for core, derive, integration, and no_std tests, but contributors had no guidance on where a regression belongs or which cargo x commands define success. A short guide keeps new tests focused and discourages another accumulation of broad snapshots and speculative abstractions. Signed-off-by: tison --- CONTRIBUTING.md | 36 ++++++++++++++++++++++++++++++++++++ README.md | 4 ++++ 2 files changed, 40 insertions(+) create mode 100644 CONTRIBUTING.md 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/README.md b/README.md index 71dbf3d..b8b46f4 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,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 [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and test conventions. + ## License This project is licensed under [Apache License, Version 2.0](LICENSE). From bcc00f5bb8d9dcf1a0dca8bb22fb85f10a37d816 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:58:32 +0800 Subject: [PATCH 13/18] build: check public documentation Why: documentation is part of this library public API, yet broken intra-doc links and docsrs-only warnings were discoverable only after publishing. Running rustdoc with warnings denied inside cargo x lint makes the normal local and CI workflow catch those failures before release. Signed-off-by: tison --- xtask/src/main.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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"); From eded2c0252a5967a7671294a77ad61f886d23117 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 18:58:57 +0800 Subject: [PATCH 14/18] docs: keep packaged README links resolvable Why: Cargo copies the workspace README into each crate package but does not include the repository-level license and contributor files beside it. Absolute repository links keep those references working when the README is rendered from a crates.io package. Signed-off-by: tison --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b8b46f4..eb12c89 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [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://github.com/fast/serde-shape/blob/main/LICENSE [actions-badge]: https://github.com/fast/serde-shape/workflows/CI/badge.svg [actions-url]: https://github.com/fast/serde-shape/actions?query=workflow%3ACI @@ -170,8 +170,8 @@ The current policy is that the minimum Rust version required to use this crate c ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and test conventions. +See the [contributor guide](https://github.com/fast/serde-shape/blob/main/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). From b30797155b2b8da21ab9d80954dc7621fc0f66fe Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 21:03:51 +0800 Subject: [PATCH 15/18] test: centralize derive graph setup Why: most derive tests care about the metadata inside a named root, but each one repeated the same graph navigation and missing-definition failure handling. Shared directional helpers keep those tests focused on their Serde contract, while recursion tests continue to inspect ShapeId values explicitly because graph identity is the behavior under test. Signed-off-by: tison --- tests/derive/tests/common/mod.rs | 47 +++++++++++++++ tests/derive/tests/derive.rs | 92 +++++++----------------------- tests/derive/tests/serde_compat.rs | 48 ++++++---------- 3 files changed, 87 insertions(+), 100 deletions(-) create mode 100644 tests/derive/tests/common/mod.rs 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 92dc1c3..049c81a 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -14,6 +14,10 @@ #![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; @@ -213,11 +217,7 @@ fn default_retries() -> u8 { #[test] fn exposes_deserialize_container_attributes() { - let graph = Config::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root() else { - panic!("root shape should be a definition"); - }; - let definition = graph.definition(*id).expect("definition exists"); + let definition = deserialize_root_definition::(); let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("definition should be a struct"); }; @@ -242,11 +242,7 @@ fn exposes_deserialize_container_attributes() { #[test] fn exposes_deserialize_enum_attributes() { - let graph = Storage::deserialize_shape(); - let ShapeRef::Definition(id) = graph.root() else { - panic!("root shape should be a definition"); - }; - let definition = graph.definition(*id).expect("definition exists"); + let definition = deserialize_root_definition::(); let DeserializeDefinitionKind::Enum(shape) = &definition.kind else { panic!("definition should be an enum"); }; @@ -259,12 +255,8 @@ fn exposes_deserialize_enum_attributes() { #[test] fn exposes_transparent_shape() { - let transparent = UserId::deserialize_shape(); - let ShapeRef::Definition(id) = transparent.root() else { - panic!("transparent root should be a definition"); - }; - let DeserializeDefinitionKind::Struct(shape) = &transparent.definition(*id).unwrap().kind - else { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("transparent definition should be a struct"); }; assert!(shape.attributes.transparent); @@ -293,11 +285,8 @@ fn applies_container_and_field_shape_overrides() { &ShapeRef::String ); - let serialize = FieldShapeOverrides::serialize_shape(); - let ShapeRef::Definition(id) = serialize.root() else { - panic!("serialize root should be a definition"); - }; - let SerializeDefinitionKind::Struct(shape) = &serialize.definition(*id).unwrap().kind else { + let definition = serialize_root_definition::(); + let SerializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("serialize definition should be a struct"); }; assert_eq!( @@ -309,12 +298,8 @@ fn applies_container_and_field_shape_overrides() { FieldWireShape::Value(ShapeRef::U8) ); - let deserialize = FieldShapeOverrides::deserialize_shape(); - let ShapeRef::Definition(id) = deserialize.root() else { - panic!("deserialize root should be a definition"); - }; - let DeserializeDefinitionKind::Struct(shape) = &deserialize.definition(*id).unwrap().kind - else { + let definition = deserialize_root_definition::(); + let DeserializeDefinitionKind::Struct(shape) = &definition.kind else { panic!("deserialize definition should be a struct"); }; assert_eq!( @@ -329,11 +314,7 @@ fn applies_container_and_field_shape_overrides() { #[test] fn preserves_rust_documentation() { - let serialize = DocumentedSetting::serialize_shape(); - let ShapeRef::Definition(id) = serialize.root() else { - panic!("serialize root should be a definition"); - }; - let definition = serialize.definition(*id).unwrap(); + let definition = serialize_root_definition::(); assert_eq!( definition.description, Some("Selects the retry policy.\n\nThis text is available to configuration tooling.") @@ -353,11 +334,7 @@ fn preserves_rust_documentation() { Some("Maximum number of retry attempts.") ); - let deserialize = DocumentedSetting::deserialize_shape(); - let ShapeRef::Definition(id) = deserialize.root() else { - panic!("deserialize root should be a definition"); - }; - let definition = deserialize.definition(*id).unwrap(); + let definition = deserialize_root_definition::(); assert_eq!( definition.description, Some("Selects the retry policy.\n\nThis text is available to configuration tooling.") @@ -429,11 +406,7 @@ fn derives_shape_bounds_for_associated_values() { #[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"); }; @@ -461,11 +434,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"); }; @@ -497,11 +466,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"); }; @@ -536,11 +501,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"); }; @@ -575,21 +536,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 0a92804..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 { From 517f9a46f799b7b585ac594bce1f7cad7670c588 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 21:16:26 +0800 Subject: [PATCH 16/18] refactor: make custom shapes function-based Why: type-only overrides make a convenience form the extension boundary and force callers to invent proxy types for representations that do not correspond to one Rust type. Function hooks can build arbitrary ShapeRef values or delegate to existing Shape implementations while composing through the active graph context. Signed-off-by: tison --- CHANGELOG.md | 2 +- README.md | 25 ++++++++----- serde-shape-derive/src/lib.rs | 46 +++++++----------------- serde-shape-derive/src/shape_attr.rs | 54 ++++++++++------------------ serde-shape/src/lib.rs | 17 +++++---- tests/derive/tests/derive.rs | 41 ++++++++++++++++++--- 6 files changed, 95 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c14c38..4361419 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ All notable changes to this project will be documented in this file. ### New features -* Add `#[serde_shape(with = "Type")]`, `serialize_as`, and `deserialize_as` overrides for custom Serde functions and foreign representations. +* 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 diff --git a/README.md b/README.md index eb12c89..dcc5f32 100644 --- a/README.md +++ b/README.md @@ -106,26 +106,35 @@ Rust doc comments on derived containers, variants, and fields are preserved as d ## Custom representations -Custom Serde functions and foreign types do not expose enough information for `serde-shape` to infer their wire representation. Declare the representation explicitly with `#[serde_shape(with = "Type")]`, or use `serialize_as` and `deserialize_as` when the two directions differ: +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, SerializeShape}; +use serde_shape::{ + DeserializeShape, DeserializeShapeContext, SerializeShape, SerializeShapeContext, ShapeRef, +}; struct ForeignDuration; -struct ForeignUrl; + +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(with = "String")] + #[serde_shape( + serialize_with = "serialize_duration", + deserialize_with = "deserialize_duration" + )] timeout: ForeignDuration, - - #[serde_shape(serialize_as = "String", deserialize_as = "String")] - endpoint: ForeignUrl, } ``` -The replacement type must implement the corresponding shape trait. An override is an assertion about the custom Serde behavior; `serde-shape` cannot verify that the declared type matches the serializer or deserializer implementation. +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 diff --git a/serde-shape-derive/src/lib.rs b/serde-shape-derive/src/lib.rs index 33fc23e..e3078ba 100644 --- a/serde-shape-derive/src/lib.rs +++ b/serde-shape-derive/src/lib.rs @@ -153,7 +153,7 @@ fn validate_shape_attrs(container: &ast::Container<'_>) -> syn::Result<()> { if !attrs.is_empty() { return Err(syn::Error::new_spanned( variant.original, - "serde_shape type overrides are supported on containers and fields, not variants", + "serde_shape custom functions are supported on containers and fields, not variants", )); } for field in &variant.fields { @@ -179,13 +179,7 @@ fn add_serialize_shape_bounds( .type_params() .map(|param| param.ident.to_string()) .collect(); - if let Some(ty) = shape_attrs.serialize_as() { - if type_uses_params(ty, &type_params) { - generics - .make_where_clause() - .predicates - .push(parse_quote!(#ty: __serde_shape::SerializeShape)); - } + if shape_attrs.serialize_with().is_some() { return Ok(()); } if container.attrs.remote().is_some() { @@ -239,13 +233,7 @@ fn add_deserialize_shape_bounds( .type_params() .map(|param| param.ident.to_string()) .collect(); - if let Some(ty) = shape_attrs.deserialize_as() { - if type_uses_params(ty, &type_params) { - generics - .make_where_clause() - .predicates - .push(parse_quote!(#ty: __serde_shape::DeserializeShape)); - } + if shape_attrs.deserialize_with().is_some() { return Ok(()); } if container.attrs.remote().is_some() { @@ -305,11 +293,7 @@ fn collect_serialize_field_bound_types( if field.attrs.skip_serializing() { continue; } - if let Some(ty) = shape_attrs.serialize_as() { - if type_uses_params(ty, type_params) { - push_bound_type(field_bound_types, ty.clone()); - } - } else if field.attrs.serialize_with().is_none() { + if shape_attrs.serialize_with().is_none() && field.attrs.serialize_with().is_none() { collect_shape_bound_types(field.ty, type_params, field_bound_types); } } @@ -326,11 +310,7 @@ fn collect_deserialize_field_bound_types( if field.attrs.skip_deserializing() { continue; } - if let Some(ty) = shape_attrs.deserialize_as() { - if type_uses_params(ty, type_params) { - push_bound_type(field_bound_types, ty.clone()); - } - } else if field.attrs.deserialize_with().is_none() { + if shape_attrs.deserialize_with().is_none() && field.attrs.deserialize_with().is_none() { collect_shape_bound_types(field.ty, type_params, field_bound_types); } } @@ -502,8 +482,8 @@ fn serialize_shape_body( container: &ast::Container<'_>, shape_attrs: &ShapeAttrs, ) -> syn::Result { - if let Some(ty) = shape_attrs.serialize_as() { - return Ok(quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(context))); + 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))); @@ -532,8 +512,8 @@ fn deserialize_shape_body( container: &ast::Container<'_>, shape_attrs: &ShapeAttrs, ) -> syn::Result { - if let Some(ty) = shape_attrs.deserialize_as() { - return Ok(quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(context))); + if let Some(function) = shape_attrs.deserialize_with() { + return Ok(quote!(#function(context))); } if let Some(ty) = container .attrs @@ -799,8 +779,8 @@ fn serialize_field_shape(field: &ast::Field<'_>) -> syn::Result { let wire_shape = if skip { quote!(__serde_shape::FieldWireShape::Omitted) } else { - let value_shape = if let Some(ty) = shape_attrs.serialize_as() { - quote!(<#ty as __serde_shape::SerializeShape>::serialize_shape_in(context)) + 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! { @@ -849,8 +829,8 @@ fn deserialize_field_shape(field: &ast::Field<'_>) -> syn::Result let wire_shape = if skip { quote!(__serde_shape::FieldWireShape::Omitted) } else { - let value_shape = if let Some(ty) = shape_attrs.deserialize_as() { - quote!(<#ty as __serde_shape::DeserializeShape>::deserialize_shape_in(context)) + 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! { diff --git a/serde-shape-derive/src/shape_attr.rs b/serde-shape-derive/src/shape_attr.rs index a026aac..629a333 100644 --- a/serde-shape-derive/src/shape_attr.rs +++ b/serde-shape-derive/src/shape_attr.rs @@ -15,17 +15,16 @@ use proc_macro2::Span; use syn::Attribute; use syn::Expr; +use syn::ExprPath; use syn::Lit; use syn::LitStr; -use syn::Type; use syn::meta::ParseNestedMeta; use syn::spanned::Spanned; #[derive(Default)] pub struct ShapeAttrs { - serialize_as: Option<(Type, Span)>, - deserialize_as: Option<(Type, Span)>, - with: Option<(Type, Span)>, + serialize_with: Option<(ExprPath, Span)>, + deserialize_with: Option<(ExprPath, Span)>, } impl ShapeAttrs { @@ -38,56 +37,39 @@ impl ShapeAttrs { } attr.parse_nested_meta(|meta| { - if meta.path.is_ident("with") { - set_once(&mut parsed.with, parse_type(&meta)?, meta.path.span()) - } else if meta.path.is_ident("serialize_as") { + if meta.path.is_ident("serialize_with") { set_once( - &mut parsed.serialize_as, - parse_type(&meta)?, + &mut parsed.serialize_with, + parse_path(&meta)?, meta.path.span(), ) - } else if meta.path.is_ident("deserialize_as") { + } else if meta.path.is_ident("deserialize_with") { set_once( - &mut parsed.deserialize_as, - parse_type(&meta)?, + &mut parsed.deserialize_with, + parse_path(&meta)?, meta.path.span(), ) } else { Err(meta.error( - "unknown serde_shape attribute; expected `with`, `serialize_as`, or `deserialize_as`", + "unknown serde_shape attribute; expected `serialize_with` or `deserialize_with`", )) } })?; } - if let Some((_, span)) = &parsed.with { - if parsed.serialize_as.is_some() || parsed.deserialize_as.is_some() { - return Err(syn::Error::new( - *span, - "`with` cannot be combined with `serialize_as` or `deserialize_as`", - )); - } - } - Ok(parsed) } - pub fn serialize_as(&self) -> Option<&Type> { - self.serialize_as - .as_ref() - .or(self.with.as_ref()) - .map(|(ty, _)| ty) + pub fn serialize_with(&self) -> Option<&ExprPath> { + self.serialize_with.as_ref().map(|(path, _)| path) } - pub fn deserialize_as(&self) -> Option<&Type> { - self.deserialize_as - .as_ref() - .or(self.with.as_ref()) - .map(|(ty, _)| ty) + pub fn deserialize_with(&self) -> Option<&ExprPath> { + self.deserialize_with.as_ref().map(|(path, _)| path) } pub fn is_empty(&self) -> bool { - self.serialize_as.is_none() && self.deserialize_as.is_none() && self.with.is_none() + self.serialize_with.is_none() && self.deserialize_with.is_none() } } @@ -125,16 +107,16 @@ pub fn description(attrs: &[Attribute]) -> Option { (!lines.is_empty()).then(|| lines.join("\n")) } -fn parse_type(meta: &ParseNestedMeta<'_>) -> syn::Result { +fn parse_path(meta: &ParseNestedMeta<'_>) -> syn::Result { let value = meta.value()?; let value: LitStr = value.parse()?; value.parse() } -fn set_once(slot: &mut Option<(Type, Span)>, ty: Type, span: Span) -> syn::Result<()> { +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((ty, span)); + *slot = Some((value, span)); Ok(()) } diff --git a/serde-shape/src/lib.rs b/serde-shape/src/lib.rs index c57053f..ec5ae50 100644 --- a/serde-shape/src/lib.rs +++ b/serde-shape/src/lib.rs @@ -162,9 +162,10 @@ //! transparent fields, and omitted fields. Custom serializer/deserializer boundaries use //! [`ShapeRef::Opaque`] and remain composable with those field positions. //! -//! Use `#[serde_shape(with = "Type")]` to declare the representation of a container or field that -//! cannot be inferred. `serialize_as` and `deserialize_as` provide direction-specific overrides. -//! The replacement type must implement the corresponding shape trait. +//! 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. @@ -228,8 +229,9 @@ 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_as = "Type")]` on a container or field to override an opaque -/// or foreign representation. `#[serde_shape(with = "Type")]` applies to both directions. +/// 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 /// @@ -270,8 +272,9 @@ 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_as = "Type")]` on a container or field to override an opaque -/// or foreign representation. `#[serde_shape(with = "Type")]` applies to both directions. +/// 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 /// diff --git a/tests/derive/tests/derive.rs b/tests/derive/tests/derive.rs index 049c81a..1ee7aa0 100644 --- a/tests/derive/tests/derive.rs +++ b/tests/derive/tests/derive.rs @@ -21,12 +21,14 @@ 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; @@ -92,15 +94,24 @@ struct IntoString(String); struct FromGeneric(T); #[derive(SerializeShape, DeserializeShape)] -#[serde_shape(serialize_as = "u16", deserialize_as = "String")] +#[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(with = "String")] + #[serde_shape( + serialize_with = "serialize_string_shape", + deserialize_with = "deserialize_string_shape" + )] custom: NotShape, - #[serde_shape(serialize_as = "u8", deserialize_as = "bool")] + #[serde_shape( + serialize_with = "serialize_u8_shape", + deserialize_with = "deserialize_bool_shape" + )] directional: NotShape, } @@ -211,6 +222,26 @@ 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 } @@ -275,10 +306,10 @@ fn follows_serde_conversion_shapes() { } #[test] -fn applies_container_and_field_shape_overrides() { +fn applies_container_and_field_custom_shape_functions() { assert_eq!( ContainerShapeOverride::serialize_shape().root(), - &ShapeRef::U16 + &ShapeRef::union([ShapeRef::U16, ShapeRef::String]) ); assert_eq!( ContainerShapeOverride::deserialize_shape().root(), From c8c097034f7e79cbe5126cd53479468ef89ed611 Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:28:49 +0800 Subject: [PATCH 17/18] docs: link license badge to Apache Why: the badge should point to the canonical Apache 2.0 license page instead of a branch-relative repository file, which can move with repository layout or default-branch changes. Signed-off-by: tison --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dcc5f32..aa141c9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [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]: https://github.com/fast/serde-shape/blob/main/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 From f8d72d3528630481c73715bccb170ced513475fd Mon Sep 17 00:00:00 2001 From: tison Date: Sat, 29 Aug 2026 22:29:08 +0800 Subject: [PATCH 18/18] docs: make contributor guide link relative Why: repository-internal documentation links should follow the checked-out branch and work in forks and local renderers instead of hard-coding one GitHub default-branch URL. Signed-off-by: tison --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aa141c9..ff94a8d 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,7 @@ The current policy is that the minimum Rust version required to use this crate c ## Contributing -See the [contributor guide](https://github.com/fast/serde-shape/blob/main/CONTRIBUTING.md) for the development workflow and test conventions. +See the [contributor guide](CONTRIBUTING.md) for the development workflow and test conventions. ## License