From 76dc592ec3b18cb13d552e39421680d4954b41ae Mon Sep 17 00:00:00 2001 From: Rain Date: Tue, 24 Feb 2026 12:10:06 -0800 Subject: [PATCH 1/2] [spr] initial version Created using spr 1.3.6-beta.1 --- README.md | 15 +- typify-impl/src/convert.rs | 377 +++++++- typify-impl/src/type_entry.rs | 105 ++- typify-test/Cargo.toml | 2 +- typify-test/build.rs | 68 ++ typify-test/src/main.rs | 61 ++ typify/tests/schemas/int-bounds.json | 111 +++ typify/tests/schemas/int-bounds.rs | 827 ++++++++++++++++++ typify/tests/schemas/merged-schemas.rs | 13 +- typify/tests/schemas/noisy-types.rs | 13 +- typify/tests/schemas/simple-types.rs | 105 ++- typify/tests/schemas/types-with-more-impls.rs | 2 +- 12 files changed, 1662 insertions(+), 37 deletions(-) create mode 100644 typify/tests/schemas/int-bounds.json create mode 100644 typify/tests/schemas/int-bounds.rs diff --git a/README.md b/README.md index 7c9d516d..435a43f2 100644 --- a/README.md +++ b/README.md @@ -339,17 +339,10 @@ types. Examples from users are very helpful in this regard. ### Bounded numbers -Bounded numbers aren't very well handled. Consider, for example, the schema: - -```json -{ - "type": "integer", - "minimum": 1, - "maximum": 6 -} -``` - -The resulting types won't enforce those value constraints. +Named integer types with sub-range bounds (e.g., a `uint8` with `maximum: 63`) +generate constrained newtypes with `TryFrom` validation. Anonymous integer +properties with bounds that don't match a standard Rust type are not yet +constrained. ### Configurable dependencies diff --git a/typify-impl/src/convert.rs b/typify-impl/src/convert.rs index 9f4da2e8..058938b0 100644 --- a/typify-impl/src/convert.rs +++ b/typify-impl/src/convert.rs @@ -201,7 +201,7 @@ impl TypeSpace { reference: None, extensions: _, } if single.as_ref() == &InstanceType::Integer => { - self.convert_integer(metadata, validation, format) + self.convert_integer(type_name, original_schema, metadata, validation, format) } // Numbers @@ -964,7 +964,9 @@ impl TypeSpace { } fn convert_integer<'a>( - &self, + &mut self, + type_name: Name, + original_schema: &'a Schema, metadata: &'a Option>, validation: &Option>, format: &Option, @@ -972,21 +974,52 @@ impl TypeSpace { let (mut min, mut max, multiple) = if let Some(validation) = validation { let min = match (&validation.minimum, &validation.exclusive_minimum) { (None, None) => None, - (None, Some(value)) => Some(value + 1.0), + // For integer types, exclusiveMinimum x means > x, so the + // tightest inclusive integer minimum is floor(x) + 1. + (None, Some(value)) => Some(value.floor() + 1.0), (Some(value), None) => Some(*value), - (Some(min), Some(emin)) => Some(min.max(emin + 1.0)), + (Some(min), Some(emin)) => Some(min.max(emin.floor() + 1.0)), }; let max = match (&validation.maximum, &validation.exclusive_maximum) { (None, None) => None, - (None, Some(value)) => Some(value - 1.0), + // Symmetrically, exclusiveMaximum x means < x, so the + // tightest inclusive integer maximum is ceil(x) - 1. + (None, Some(value)) => Some(value.ceil() - 1.0), (Some(value), None) => Some(*value), - (Some(max), Some(emax)) => Some(max.min(emax - 1.0)), + (Some(max), Some(emax)) => Some(max.min(emax.ceil() - 1.0)), }; (min, max, validation.multiple_of) } else { (None, None, None) }; + // Reject non-finite bounds early. NaN poisons all + // downstream comparisons, and infinity would saturate the + // later f64-to-i128 casts. + for bound in [min, max] { + if let Some(v) = bound { + if !v.is_finite() { + return Err(Error::InvalidSchema { + type_name: type_name.clone().into_option(), + reason: format!( + "non-finite bound value: {}", + v, + ), + }); + } + } + } + + // Reject contradictory bounds (min > max). + if let (Some(lo), Some(hi)) = (min, max) { + if lo > hi { + return Err(Error::InvalidSchema { + type_name: type_name.into_option(), + reason: format!("minimum ({}) is greater than maximum ({})", lo, hi,), + }); + } + } + // Ordered from most- to least-restrictive. // JSONSchema format, Rust Type, Rust NonZero Type, Rust type min, Rust type max let formats: &[(&str, &str, &str, f64, f64)] = &[ @@ -1085,10 +1118,70 @@ impl TypeSpace { } } - // Use NonZero types for minimum 1 - if min == Some(1.) { + let min_is_exact = min.map_or(true, |m| (m - *imin).abs() <= f64::EPSILON); + let max_is_exact = max.map_or(true, |m| (m - *imax).abs() <= f64::EPSILON); + + if min == Some(1.) + && (max_is_exact || get_type_name(&type_name, metadata).is_none()) + { + // Bounds match a NonZero type exactly, or this is + // an anonymous type where NonZero is the best we + // can do. return Ok((TypeEntry::new_integer(nz_ty), metadata)); + } else if min_is_exact && max_is_exact { + // Bounds match the format type exactly. + return Ok((TypeEntry::new_integer(ty), metadata)); + } else if get_type_name(&type_name, metadata).is_some() { + // Sub-range bounds on a named type: generate a + // constrained newtype. Only pass bounds that are + // actually a sub-range of the inner type to avoid + // redundant checks which result in a warning (e.g., + // `value < 0` on u8). + // + // For min >= 1 we could use a NonZero type, but that + // has a number of downstream complications (e.g., + // needing to use `.get()` during bounds checks). + // Convert fractional bounds to the tightest + // integer range: ceil for min, floor for max. + let effective_min = min + .filter(|_| !min_is_exact) + .map(|v| v.ceil() as i128); + let effective_max = max + .filter(|_| !max_is_exact) + .map(|v| v.floor() as i128); + // Rounding can invert bounds (e.g. min=0.5, + // max=0.5 becomes 1, 0). + if let (Some(lo), Some(hi)) = (effective_min, effective_max) { + if lo > hi { + return Err(Error::InvalidSchema { + type_name: type_name.into_option(), + reason: format!( + "no valid integers in range \ + (effective minimum {} > \ + effective maximum {} after \ + rounding fractional bounds)", + lo, hi, + ), + }); + } + } + let inner = TypeEntry::new_integer(ty); + let inner_id = self.assign_type(inner); + return Ok(( + TypeEntryNewtype::from_metadata_with_integer_validation( + self, + type_name, + metadata, + inner_id, + effective_min, + effective_max, + original_schema.clone(), + ), + metadata, + )); } else { + // Anonymous sub-range: return the format type + // without constraints. return Ok((TypeEntry::new_integer(ty), metadata)); } } @@ -1139,7 +1232,7 @@ impl TypeSpace { }), (Some(min), Some(max)) => { formats.iter().rev().find_map(|(_, ty, nz_ty, imin, imax)| { - if min == 1. { + if min == 1. && (imax - max).abs() <= f64::EPSILON { Some(nz_ty.to_string()) } else if (imax - max).abs() <= f64::EPSILON && (imin - min).abs() <= f64::EPSILON @@ -1156,11 +1249,64 @@ impl TypeSpace { // TODO we should do something with `multiple` if let Some(ty) = maybe_type { Ok((TypeEntry::new_integer(ty), metadata)) + } else if get_type_name(&type_name, metadata).is_some() && (min.is_some() || max.is_some()) + { + // Find the smallest standard integer type that contains the + // given range, then wrap it in a constrained newtype. + // Prefer unsigned types when min >= 0. + let non_negative = min.map_or(false, |m| m >= 0.0); + let (containing_ty, imin, imax) = formats + .iter() + .find(|(fmt, _, _, imin, imax)| { + if non_negative && fmt.starts_with("int") { + return false; + } + min.map_or(true, |m| m >= *imin) && max.map_or(true, |m| m <= *imax) + }) + .map(|(_, ty, _, imin, imax)| (*ty, *imin, *imax)) + .unwrap_or(("i64", i64::MIN as f64, i64::MAX as f64)); + // Only pass bounds that are actually a sub-range of the + // containing type. Convert fractional bounds to the + // tightest integer range: ceil for min, floor for max. + let effective_min = min + .filter(|m| (m - imin).abs() > f64::EPSILON) + .map(|v| v.ceil() as i128); + let effective_max = max + .filter(|m| (m - imax).abs() > f64::EPSILON) + .map(|v| v.floor() as i128); + // Rounding can invert bounds (e.g. min=0.5, max=0.5 + // becomes 1, 0). + if let (Some(lo), Some(hi)) = (effective_min, effective_max) { + if lo > hi { + return Err(Error::InvalidSchema { + type_name: type_name.into_option(), + reason: format!( + "no valid integers in range \ + (effective minimum {} > \ + effective maximum {} after \ + rounding fractional bounds)", + lo, hi, + ), + }); + } + } + let inner = TypeEntry::new_integer(containing_ty); + let inner_id = self.assign_type(inner); + Ok(( + TypeEntryNewtype::from_metadata_with_integer_validation( + self, + type_name, + metadata, + inner_id, + effective_min, + effective_max, + original_schema.clone(), + ), + metadata, + )) } else { - // TODO we could construct a type that itself enforces the various - // bounds. - // TODO failing that, we should find the type that most tightly - // matches these bounds. + // TODO we should find the type that most tightly matches + // these bounds. Ok((TypeEntry::new_integer("i64"), metadata)) } } @@ -2137,6 +2283,37 @@ mod tests { int_test!(NonZeroU32); int_test!(NonZeroU64); + /// Helper to attempt conversion of a named integer schema, returning + /// the result (used for error-path tests). + fn try_convert_named_integer( + name: &str, + format: Option<&str>, + minimum: Option, + maximum: Option, + ) -> Result<(), crate::Error> { + let number = if minimum.is_some() || maximum.is_some() { + Some(Box::new(NumberValidation { + minimum, + maximum, + ..Default::default() + })) + } else { + None + }; + + let schema = SchemaObject { + instance_type: Some(InstanceType::Integer.into()), + format: format.map(String::from), + number, + ..Default::default() + }; + + let mut type_space = TypeSpace::default(); + let schema_obj = schemars::schema::Schema::Object(schema.clone()); + type_space.convert_schema_object(Name::Required(name.to_string()), &schema_obj, &schema)?; + Ok(()) + } + #[test] fn test_redundant_types() { #[derive(JsonSchema)] @@ -2387,4 +2564,178 @@ mod tests { Some("An integer value") ); } + + #[test] + fn test_min_greater_than_max() { + let result = try_convert_named_integer("Bad", Some("uint8"), Some(100.0), Some(50.0)); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!(reason, "minimum (100) is greater than maximum (50)"); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_min_greater_than_max_no_format() { + let result = try_convert_named_integer("Bad", None, Some(100.0), Some(50.0)); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!(reason, "minimum (100) is greater than maximum (50)"); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_non_finite_minimum() { + let result = try_convert_named_integer( + "Bad", + Some("uint8"), + Some(f64::NAN), + Some(63.0), + ); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!(reason, "non-finite bound value: NaN"); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_non_finite_maximum() { + let result = try_convert_named_integer( + "Bad", + Some("uint8"), + Some(0.0), + Some(f64::INFINITY), + ); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!(reason, "non-finite bound value: inf"); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_non_finite_negative_infinity() { + let result = try_convert_named_integer( + "Bad", + None, + Some(f64::NEG_INFINITY), + Some(10.0), + ); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!(reason, "non-finite bound value: -inf"); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_fractional_bounds_invert_after_rounding_with_format() { + // minimum=0.5, maximum=0.5 passes the f64 min>max check, but + // after rounding (ceil for min, floor for max) becomes 1 > 0. + let result = + try_convert_named_integer("Bad", Some("uint8"), Some(0.5), Some(0.5)); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!( + reason, + "no valid integers in range \ + (effective minimum 1 > \ + effective maximum 0 after \ + rounding fractional bounds)", + ); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_fractional_bounds_invert_after_rounding_no_format() { + // Same scenario without a format hint: exercises the + // no-format constrained newtype path. + let result = try_convert_named_integer("Bad", None, Some(0.5), Some(0.5)); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!( + reason, + "no valid integers in range \ + (effective minimum 1 > \ + effective maximum 0 after \ + rounding fractional bounds)", + ); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_fractional_bounds_invert_after_rounding_negative() { + // Negative fractional: min=ceil(-0.5)=0, max=floor(-0.5)=-1. + let result = + try_convert_named_integer("Bad", Some("int8"), Some(-0.5), Some(-0.5)); + match result { + Err(Error::InvalidSchema { type_name, reason }) => { + assert_eq!(type_name.as_deref(), Some("Bad")); + assert_eq!( + reason, + "no valid integers in range \ + (effective minimum 0 > \ + effective maximum -1 after \ + rounding fractional bounds)", + ); + } + other => panic!("expected InvalidSchema, got {other:?}"), + } + } + + #[test] + fn test_fractional_bounds_valid_after_rounding() { + // min=0.5, max=1.5 rounds to min=1, max=1: a single valid + // value, which is fine. + try_convert_named_integer("Ok", Some("uint8"), Some(0.5), Some(1.5)) + .expect("single-value range should be accepted"); + } + + #[test] + fn test_float_precision_common_values() { + // Verify that common integer bounds survive f64 → i128 without + // corruption. All integers up to 2^53 are exactly representable + // in f64. + for v in [ + 0, + 1, + 17, + 63, + 127, + 255, + 1000, + 65535, + 100_000, + i32::MAX as i128, + ] { + let f = v as f64; + assert_eq!(f.floor() as i128, v, "floor round-trip failed for {v}",); + assert_eq!(f.ceil() as i128, v, "ceil round-trip failed for {v}",); + } + + // Negative values too. + for v in [-1, -10, -50, -128, -1000, -32768, i32::MIN as i128] { + let f = v as f64; + assert_eq!(f.floor() as i128, v, "floor failed for {v}"); + assert_eq!(f.ceil() as i128, v, "ceil failed for {v}"); + } + } } diff --git a/typify-impl/src/type_entry.rs b/typify-impl/src/type_entry.rs index b79a42c8..07963429 100644 --- a/typify-impl/src/type_entry.rs +++ b/typify-impl/src/type_entry.rs @@ -90,6 +90,10 @@ pub(crate) enum TypeEntryNewtypeConstraints { min_length: Option, pattern: Option, }, + Integer { + min: Option, + max: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] @@ -540,6 +544,39 @@ impl TypeEntryNewtype { extra_attrs: type_patch.attrs, } } + + pub(crate) fn from_metadata_with_integer_validation( + type_space: &TypeSpace, + type_name: Name, + metadata: &Option>, + type_id: TypeId, + min: Option, + max: Option, + schema: Schema, + ) -> TypeEntry { + let name = get_type_name(&type_name, metadata) + .expect("type name required for constrained integer newtype"); + let rename = None; + let description = metadata_description(metadata); + + let type_patch = TypePatch::new(type_space, name); + + let details = TypeEntryDetails::Newtype(Self { + name: type_patch.name, + rename, + description, + default: None, + type_id, + constraints: TypeEntryNewtypeConstraints::Integer { min, max }, + schema: SchemaWrapper(schema), + }); + + TypeEntry { + details, + extra_derives: type_patch.derives, + extra_attrs: type_patch.attrs, + } + } } impl From for TypeEntry { @@ -1378,12 +1415,16 @@ impl TypeEntry { let inner_type_name = inner_type.type_ident(type_space, &None); let is_str = matches!(inner_type.details, TypeEntryDetails::String); + let is_int = matches!(inner_type.details, TypeEntryDetails::Integer(_)); - // If this is just a wrapper around a string, we can derive some more - // useful traits. - if is_str { + // If this is just a wrapper around a string or integer, we can derive + // some more useful traits. + if is_str || is_int { derive_set.extend(["PartialOrd", "Ord", "PartialEq", "Eq", "Hash"]); } + if is_int { + derive_set.insert("Copy"); + } let constraint_impl = match constraints { // In the unconstrained case we proxy impls through the inner type. @@ -1629,6 +1670,64 @@ impl TypeEntry { } } } + + TypeEntryNewtypeConstraints::Integer { min, max } => { + let min_check = min.map(|v| { + let lit = proc_macro2::Literal::i128_unsuffixed(v); + let err = format!("value must be at least {}", v); + quote! { + if value < #lit { + return Err(#err.into()); + } + } + }); + let max_check = max.map(|v| { + let lit = proc_macro2::Literal::i128_unsuffixed(v); + let err = format!("value must be at most {}", v); + quote! { + if value > #lit { + return Err(#err.into()); + } + } + }); + + // We're going to impl Deserialize so we can remove it + // from the set of derived impls. + derive_set.remove("::serde::Deserialize"); + + quote! { + impl ::std::convert::TryFrom<#inner_type_name> for #type_name { + type Error = self::error::ConversionError; + + fn try_from( + value: #inner_type_name, + ) -> ::std::result::Result + { + #min_check + #max_check + Ok(Self(value)) + } + } + + impl<'de> ::serde::Deserialize<'de> for #type_name { + fn deserialize( + deserializer: D, + ) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from( + <#inner_type_name>::deserialize(deserializer)?, + ) + .map_err(|e| { + ::custom( + e.to_string(), + ) + }) + } + } + } + } }; // If there are no constraints, let consumers directly access the value. diff --git a/typify-test/Cargo.toml b/typify-test/Cargo.toml index 8e134288..a3fa4871 100644 --- a/typify-test/Cargo.toml +++ b/typify-test/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] regress = { workspace = true } -serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } [build-dependencies] diff --git a/typify-test/build.rs b/typify-test/build.rs index f5c77373..e3d28b70 100644 --- a/typify-test/build.rs +++ b/typify-test/build.rs @@ -99,6 +99,71 @@ impl JsonSchema for NonAsciiChars { } } +/// A uint8 bounded to [1, 63]. +struct BoundedUint; +impl JsonSchema for BoundedUint { + fn schema_name() -> String { + "BoundedUint".to_string() + } + + fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + format: Some("uint8".to_string()), + number: Some(Box::new(schemars::schema::NumberValidation { + minimum: Some(1.0), + maximum: Some(63.0), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +/// An int16 bounded to [-50, -10]. +struct BoundedNegative; +impl JsonSchema for BoundedNegative { + fn schema_name() -> String { + "BoundedNegative".to_string() + } + + fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + format: Some("int16".to_string()), + number: Some(Box::new(schemars::schema::NumberValidation { + minimum: Some(-50.0), + maximum: Some(-10.0), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + +/// No format hint, bounds [0, 63] -- should infer u8. +struct InferredUint; +impl JsonSchema for InferredUint { + fn schema_name() -> String { + "InferredUint".to_string() + } + + fn json_schema(_: &mut schemars::gen::SchemaGenerator) -> Schema { + schemars::schema::SchemaObject { + instance_type: Some(schemars::schema::InstanceType::Integer.into()), + number: Some(Box::new(schemars::schema::NumberValidation { + minimum: Some(0.0), + maximum: Some(63.0), + ..Default::default() + })), + ..Default::default() + } + .into() + } +} + struct Pancakes; impl JsonSchema for Pancakes { fn schema_name() -> String { @@ -132,6 +197,9 @@ fn main() { LoginName::add(&mut type_space); NonAsciiChars::add(&mut type_space); UnknownFormat::add(&mut type_space); + BoundedUint::add(&mut type_space); + BoundedNegative::add(&mut type_space); + InferredUint::add(&mut type_space); ipnetwork::IpNetwork::add(&mut type_space); let contents = diff --git a/typify-test/src/main.rs b/typify-test/src/main.rs index 3ae1bc88..c2eee83d 100644 --- a/typify-test/src/main.rs +++ b/typify-test/src/main.rs @@ -48,6 +48,67 @@ fn test_string_constraints_for_non_ascii_chars() { assert!(NonAsciiChars::try_from("🍔").is_err()); } +#[test] +fn test_integer_bounds_try_from() { + // BoundedUint: u8 in [1, 63]. + assert!(BoundedUint::try_from(0u8).is_err()); + assert!(BoundedUint::try_from(1u8).is_ok()); + assert!(BoundedUint::try_from(63u8).is_ok()); + assert!(BoundedUint::try_from(64u8).is_err()); + assert!(BoundedUint::try_from(255u8).is_err()); + + // Verify the inner value round-trips. + let v = BoundedUint::try_from(42u8).unwrap(); + assert_eq!(*v, 42u8); + assert_eq!(u8::from(v), 42u8); +} + +#[test] +fn test_integer_bounds_negative() { + // BoundedNegative: i16 in [-50, -10]. + assert!(BoundedNegative::try_from(-51i16).is_err()); + assert!(BoundedNegative::try_from(-50i16).is_ok()); + assert!(BoundedNegative::try_from(-10i16).is_ok()); + assert!(BoundedNegative::try_from(-9i16).is_err()); + assert!(BoundedNegative::try_from(0i16).is_err()); +} + +#[test] +fn test_integer_bounds_inferred() { + // InferredUint: no format hint, [0, 63] -> inferred u8. + assert!(InferredUint::try_from(0u8).is_ok()); + assert!(InferredUint::try_from(63u8).is_ok()); + assert!(InferredUint::try_from(64u8).is_err()); +} + +#[test] +fn test_integer_bounds_serde() { + // Deserialization should enforce bounds. + let ok: Result = serde_json::from_str("42"); + assert!(ok.is_ok()); + assert_eq!(*ok.unwrap(), 42u8); + + let too_high: Result = serde_json::from_str("64"); + assert!(too_high.is_err()); + + let too_low: Result = serde_json::from_str("0"); + assert!(too_low.is_err()); + + // Serialization should produce the bare integer. + let v = BoundedUint::try_from(42u8).unwrap(); + assert_eq!(serde_json::to_string(&v).unwrap(), "42"); + + // Negative bounds. + let ok: Result = serde_json::from_str("-25"); + assert!(ok.is_ok()); + + let too_low: Result = serde_json::from_str("-51"); + assert!(too_low.is_err()); + + let too_high: Result = serde_json::from_str("-9"); + assert!(too_high.is_err()); +} + #[test] fn test_unknown_format() { // An unknown format string should just render as a string. diff --git a/typify/tests/schemas/int-bounds.json b/typify/tests/schemas/int-bounds.json new file mode 100644 index 00000000..810eb35f --- /dev/null +++ b/typify/tests/schemas/int-bounds.json @@ -0,0 +1,111 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "definitions": { + "bounded-uint": { + "description": "A uint8 value between 0 and 63.", + "type": "integer", + "format": "uint8", + "minimum": 0, + "maximum": 63 + }, + "bounded-negative": { + "description": "A negative-only range.", + "type": "integer", + "format": "int16", + "minimum": -50, + "maximum": -10 + }, + "bounded-mixed-sign": { + "description": "A range spanning negative and positive.", + "type": "integer", + "format": "int32", + "minimum": -1000, + "maximum": 1000 + }, + "fractional-bounds-positive": { + "description": "Fractional bounds that must be rounded: min=0.5 -> ceil -> 1, max=63.7 -> floor -> 63.", + "type": "integer", + "format": "uint8", + "minimum": 0.5, + "maximum": 63.7 + }, + "fractional-bounds-negative": { + "description": "Negative fractional bounds: min=-10.3 -> ceil -> -10, max=-0.1 -> floor -> -1.", + "type": "integer", + "format": "int8", + "minimum": -10.3, + "maximum": -0.1 + }, + "no-format-unsigned": { + "description": "No format hint, non-negative range: should pick u8.", + "type": "integer", + "minimum": 0, + "maximum": 63 + }, + "no-format-signed": { + "description": "No format hint, signed range: should pick i8.", + "type": "integer", + "minimum": -50, + "maximum": 50 + }, + "no-format-large-unsigned": { + "description": "No format hint, large unsigned range: should pick u32.", + "type": "integer", + "minimum": 0, + "maximum": 100000 + }, + "exact-u8-match": { + "description": "Exact u8 bounds: should not produce a constrained newtype.", + "type": "integer", + "format": "uint8", + "minimum": 0, + "maximum": 255 + }, + "exact-nonzero-u8-match": { + "description": "min=1, max=255 matches NonZeroU8 exactly: should not produce a constrained newtype.", + "type": "integer", + "format": "uint8", + "minimum": 1, + "maximum": 255 + }, + "min-only-sub-range": { + "description": "Only a min bound, no max. min=10 with uint8 format.", + "type": "integer", + "format": "uint8", + "minimum": 10 + }, + "max-only-sub-range": { + "description": "Only a max bound, no min. max=200 with uint8 format.", + "type": "integer", + "format": "uint8", + "maximum": 200 + }, + "nonzero-with-max": { + "description": "min=1 with sub-range max: should be a constrained newtype, not NonZeroU8.", + "type": "integer", + "format": "uint8", + "minimum": 1, + "maximum": 100 + }, + "exclusive-integer-bounds": { + "description": "exclusiveMinimum=0, exclusiveMaximum=64 should produce min=1, max=63.", + "type": "integer", + "format": "uint8", + "exclusiveMinimum": 0, + "exclusiveMaximum": 64 + }, + "exclusive-fractional-bounds": { + "description": "exclusiveMinimum=0.5 should produce min=1, exclusiveMaximum=63.5 should produce max=63.", + "type": "integer", + "format": "uint8", + "exclusiveMinimum": 0.5, + "exclusiveMaximum": 63.5 + }, + "no-format-min1-with-max": { + "description": "No format, min=1, max=10: should be a constrained newtype, not NonZero.", + "type": "integer", + "minimum": 1, + "maximum": 10 + } + } +} diff --git a/typify/tests/schemas/int-bounds.rs b/typify/tests/schemas/int-bounds.rs new file mode 100644 index 00000000..825c7ca2 --- /dev/null +++ b/typify/tests/schemas/int-bounds.rs @@ -0,0 +1,827 @@ +#![deny(warnings)] +#[doc = r" Error types."] +pub mod error { + #[doc = r" Error from a `TryFrom` or `FromStr` implementation."] + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +#[doc = "A range spanning negative and positive."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"A range spanning negative and positive.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"int32\","] +#[doc = " \"maximum\": 1000.0,"] +#[doc = " \"minimum\": -1000.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct BoundedMixedSign(i32); +impl ::std::ops::Deref for BoundedMixedSign { + type Target = i32; + fn deref(&self) -> &i32 { + &self.0 + } +} +impl ::std::convert::From for i32 { + fn from(value: BoundedMixedSign) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for BoundedMixedSign { + type Error = self::error::ConversionError; + fn try_from(value: i32) -> ::std::result::Result { + if value < -1000 { + return Err("value must be at least -1000".into()); + } + if value > 1000 { + return Err("value must be at most 1000".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for BoundedMixedSign { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "A negative-only range."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"A negative-only range.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"int16\","] +#[doc = " \"maximum\": -10.0,"] +#[doc = " \"minimum\": -50.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct BoundedNegative(i16); +impl ::std::ops::Deref for BoundedNegative { + type Target = i16; + fn deref(&self) -> &i16 { + &self.0 + } +} +impl ::std::convert::From for i16 { + fn from(value: BoundedNegative) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for BoundedNegative { + type Error = self::error::ConversionError; + fn try_from(value: i16) -> ::std::result::Result { + if value < -50 { + return Err("value must be at least -50".into()); + } + if value > -10 { + return Err("value must be at most -10".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for BoundedNegative { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "A uint8 value between 0 and 63."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"A uint8 value between 0 and 63.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"maximum\": 63.0,"] +#[doc = " \"minimum\": 0.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct BoundedUint(u8); +impl ::std::ops::Deref for BoundedUint { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: BoundedUint) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for BoundedUint { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value > 63 { + return Err("value must be at most 63".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for BoundedUint { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "min=1, max=255 matches NonZeroU8 exactly: should not produce a constrained newtype."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"min=1, max=255 matches NonZeroU8 exactly: should not produce a constrained newtype.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"maximum\": 255.0,"] +#[doc = " \"minimum\": 1.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +#[serde(transparent)] +pub struct ExactNonzeroU8Match(pub ::std::num::NonZeroU8); +impl ::std::ops::Deref for ExactNonzeroU8Match { + type Target = ::std::num::NonZeroU8; + fn deref(&self) -> &::std::num::NonZeroU8 { + &self.0 + } +} +impl ::std::convert::From for ::std::num::NonZeroU8 { + fn from(value: ExactNonzeroU8Match) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::num::NonZeroU8> for ExactNonzeroU8Match { + fn from(value: ::std::num::NonZeroU8) -> Self { + Self(value) + } +} +impl ::std::str::FromStr for ExactNonzeroU8Match { + type Err = <::std::num::NonZeroU8 as ::std::str::FromStr>::Err; + fn from_str(value: &str) -> ::std::result::Result { + Ok(Self(value.parse()?)) + } +} +impl ::std::convert::TryFrom<&str> for ExactNonzeroU8Match { + type Error = <::std::num::NonZeroU8 as ::std::str::FromStr>::Err; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom for ExactNonzeroU8Match { + type Error = <::std::num::NonZeroU8 as ::std::str::FromStr>::Err; + fn try_from(value: String) -> ::std::result::Result { + value.parse() + } +} +impl ::std::fmt::Display for ExactNonzeroU8Match { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.fmt(f) + } +} +#[doc = "Exact u8 bounds: should not produce a constrained newtype."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"Exact u8 bounds: should not produce a constrained newtype.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"maximum\": 255.0,"] +#[doc = " \"minimum\": 0.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] +#[serde(transparent)] +pub struct ExactU8Match(pub u8); +impl ::std::ops::Deref for ExactU8Match { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: ExactU8Match) -> Self { + value.0 + } +} +impl ::std::convert::From for ExactU8Match { + fn from(value: u8) -> Self { + Self(value) + } +} +impl ::std::str::FromStr for ExactU8Match { + type Err = ::Err; + fn from_str(value: &str) -> ::std::result::Result { + Ok(Self(value.parse()?)) + } +} +impl ::std::convert::TryFrom<&str> for ExactU8Match { + type Error = ::Err; + fn try_from(value: &str) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom for ExactU8Match { + type Error = ::Err; + fn try_from(value: String) -> ::std::result::Result { + value.parse() + } +} +impl ::std::fmt::Display for ExactU8Match { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + self.0.fmt(f) + } +} +#[doc = "exclusiveMinimum=0.5 should produce min=1, exclusiveMaximum=63.5 should produce max=63."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"exclusiveMinimum=0.5 should produce min=1, exclusiveMaximum=63.5 should produce max=63.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"exclusiveMaximum\": 63.5,"] +#[doc = " \"exclusiveMinimum\": 0.5"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ExclusiveFractionalBounds(u8); +impl ::std::ops::Deref for ExclusiveFractionalBounds { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: ExclusiveFractionalBounds) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for ExclusiveFractionalBounds { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value < 1 { + return Err("value must be at least 1".into()); + } + if value > 63 { + return Err("value must be at most 63".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for ExclusiveFractionalBounds { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "exclusiveMinimum=0, exclusiveMaximum=64 should produce min=1, max=63."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"exclusiveMinimum=0, exclusiveMaximum=64 should produce min=1, max=63.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"exclusiveMaximum\": 64.0,"] +#[doc = " \"exclusiveMinimum\": 0.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct ExclusiveIntegerBounds(u8); +impl ::std::ops::Deref for ExclusiveIntegerBounds { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: ExclusiveIntegerBounds) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for ExclusiveIntegerBounds { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value < 1 { + return Err("value must be at least 1".into()); + } + if value > 63 { + return Err("value must be at most 63".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for ExclusiveIntegerBounds { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "Negative fractional bounds: min=-10.3 -> ceil -> -10, max=-0.1 -> floor -> -1."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"Negative fractional bounds: min=-10.3 -> ceil -> -10, max=-0.1 -> floor -> -1.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"int8\","] +#[doc = " \"maximum\": -0.1,"] +#[doc = " \"minimum\": -10.3"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct FractionalBoundsNegative(i8); +impl ::std::ops::Deref for FractionalBoundsNegative { + type Target = i8; + fn deref(&self) -> &i8 { + &self.0 + } +} +impl ::std::convert::From for i8 { + fn from(value: FractionalBoundsNegative) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for FractionalBoundsNegative { + type Error = self::error::ConversionError; + fn try_from(value: i8) -> ::std::result::Result { + if value < -10 { + return Err("value must be at least -10".into()); + } + if value > -1 { + return Err("value must be at most -1".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for FractionalBoundsNegative { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "Fractional bounds that must be rounded: min=0.5 -> ceil -> 1, max=63.7 -> floor -> 63."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"Fractional bounds that must be rounded: min=0.5 -> ceil -> 1, max=63.7 -> floor -> 63.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"maximum\": 63.7,"] +#[doc = " \"minimum\": 0.5"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct FractionalBoundsPositive(u8); +impl ::std::ops::Deref for FractionalBoundsPositive { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: FractionalBoundsPositive) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for FractionalBoundsPositive { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value < 1 { + return Err("value must be at least 1".into()); + } + if value > 63 { + return Err("value must be at most 63".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for FractionalBoundsPositive { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "Only a max bound, no min. max=200 with uint8 format."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"Only a max bound, no min. max=200 with uint8 format.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"maximum\": 200.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct MaxOnlySubRange(u8); +impl ::std::ops::Deref for MaxOnlySubRange { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: MaxOnlySubRange) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for MaxOnlySubRange { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value > 200 { + return Err("value must be at most 200".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for MaxOnlySubRange { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "Only a min bound, no max. min=10 with uint8 format."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"Only a min bound, no max. min=10 with uint8 format.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"minimum\": 10.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct MinOnlySubRange(u8); +impl ::std::ops::Deref for MinOnlySubRange { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: MinOnlySubRange) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for MinOnlySubRange { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value < 10 { + return Err("value must be at least 10".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for MinOnlySubRange { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "No format hint, large unsigned range: should pick u32."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"No format hint, large unsigned range: should pick u32.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"maximum\": 100000.0,"] +#[doc = " \"minimum\": 0.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct NoFormatLargeUnsigned(u32); +impl ::std::ops::Deref for NoFormatLargeUnsigned { + type Target = u32; + fn deref(&self) -> &u32 { + &self.0 + } +} +impl ::std::convert::From for u32 { + fn from(value: NoFormatLargeUnsigned) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for NoFormatLargeUnsigned { + type Error = self::error::ConversionError; + fn try_from(value: u32) -> ::std::result::Result { + if value > 100000 { + return Err("value must be at most 100000".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for NoFormatLargeUnsigned { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "No format, min=1, max=10: should be a constrained newtype, not NonZero."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"No format, min=1, max=10: should be a constrained newtype, not NonZero.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"maximum\": 10.0,"] +#[doc = " \"minimum\": 1.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct NoFormatMin1WithMax(u8); +impl ::std::ops::Deref for NoFormatMin1WithMax { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: NoFormatMin1WithMax) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for NoFormatMin1WithMax { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value < 1 { + return Err("value must be at least 1".into()); + } + if value > 10 { + return Err("value must be at most 10".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for NoFormatMin1WithMax { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "No format hint, signed range: should pick i8."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"No format hint, signed range: should pick i8.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"maximum\": 50.0,"] +#[doc = " \"minimum\": -50.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct NoFormatSigned(i8); +impl ::std::ops::Deref for NoFormatSigned { + type Target = i8; + fn deref(&self) -> &i8 { + &self.0 + } +} +impl ::std::convert::From for i8 { + fn from(value: NoFormatSigned) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for NoFormatSigned { + type Error = self::error::ConversionError; + fn try_from(value: i8) -> ::std::result::Result { + if value < -50 { + return Err("value must be at least -50".into()); + } + if value > 50 { + return Err("value must be at most 50".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for NoFormatSigned { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "No format hint, non-negative range: should pick u8."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"No format hint, non-negative range: should pick u8.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"maximum\": 63.0,"] +#[doc = " \"minimum\": 0.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct NoFormatUnsigned(u8); +impl ::std::ops::Deref for NoFormatUnsigned { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: NoFormatUnsigned) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for NoFormatUnsigned { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value > 63 { + return Err("value must be at most 63".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for NoFormatUnsigned { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "min=1 with sub-range max: should be a constrained newtype, not NonZeroU8."] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"description\": \"min=1 with sub-range max: should be a constrained newtype, not NonZeroU8.\","] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint8\","] +#[doc = " \"maximum\": 100.0,"] +#[doc = " \"minimum\": 1.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct NonzeroWithMax(u8); +impl ::std::ops::Deref for NonzeroWithMax { + type Target = u8; + fn deref(&self) -> &u8 { + &self.0 + } +} +impl ::std::convert::From for u8 { + fn from(value: NonzeroWithMax) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for NonzeroWithMax { + type Error = self::error::ConversionError; + fn try_from(value: u8) -> ::std::result::Result { + if value < 1 { + return Err("value must be at least 1".into()); + } + if value > 100 { + return Err("value must be at most 100".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for NonzeroWithMax { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +fn main() {} diff --git a/typify/tests/schemas/merged-schemas.rs b/typify/tests/schemas/merged-schemas.rs index e1b68262..e77a8b16 100644 --- a/typify/tests/schemas/merged-schemas.rs +++ b/typify/tests/schemas/merged-schemas.rs @@ -498,7 +498,18 @@ impl MergeEmpty { #[doc = "}"] #[doc = r" ```"] #[doc = r" "] -#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] #[serde(transparent)] pub struct NarrowNumber(pub ::std::num::NonZeroU64); impl ::std::ops::Deref for NarrowNumber { diff --git a/typify/tests/schemas/noisy-types.rs b/typify/tests/schemas/noisy-types.rs index f6bed45a..48068a4e 100644 --- a/typify/tests/schemas/noisy-types.rs +++ b/typify/tests/schemas/noisy-types.rs @@ -83,7 +83,18 @@ impl ::std::convert::From<::std::vec::Vec> for ArrayBs { #[doc = "}"] #[doc = r" ```"] #[doc = r" "] -#[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] +#[derive( + :: serde :: Deserialize, + :: serde :: Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd, +)] #[serde(transparent)] pub struct IntegerBs(pub u64); impl ::std::ops::Deref for IntegerBs { diff --git a/typify/tests/schemas/simple-types.rs b/typify/tests/schemas/simple-types.rs index 3f197c09..7de5b847 100644 --- a/typify/tests/schemas/simple-types.rs +++ b/typify/tests/schemas/simple-types.rs @@ -183,9 +183,9 @@ impl ::std::fmt::Display for JustOne { #[doc = r" "] #[derive(:: serde :: Deserialize, :: serde :: Serialize, Clone, Debug)] pub struct UintMinimumAndMaximum { - pub max: u64, + pub max: UintMinimumAndMaximumMax, pub min: u64, - pub min_and_max: ::std::num::NonZeroU64, + pub min_and_max: UintMinimumAndMaximumMinAndMax, pub min_non_zero: ::std::num::NonZeroU64, pub min_uint_non_zero: ::std::num::NonZeroU64, pub no_bounds: u64, @@ -195,6 +195,98 @@ impl UintMinimumAndMaximum { Default::default() } } +#[doc = "`UintMinimumAndMaximumMax`"] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint64\","] +#[doc = " \"maximum\": 256.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct UintMinimumAndMaximumMax(u64); +impl ::std::ops::Deref for UintMinimumAndMaximumMax { + type Target = u64; + fn deref(&self) -> &u64 { + &self.0 + } +} +impl ::std::convert::From for u64 { + fn from(value: UintMinimumAndMaximumMax) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for UintMinimumAndMaximumMax { + type Error = self::error::ConversionError; + fn try_from(value: u64) -> ::std::result::Result { + if value > 256 { + return Err("value must be at most 256".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for UintMinimumAndMaximumMax { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} +#[doc = "`UintMinimumAndMaximumMinAndMax`"] +#[doc = r""] +#[doc = r"
JSON schema"] +#[doc = r""] +#[doc = r" ```json"] +#[doc = "{"] +#[doc = " \"type\": \"integer\","] +#[doc = " \"format\": \"uint64\","] +#[doc = " \"maximum\": 256.0,"] +#[doc = " \"minimum\": 1.0"] +#[doc = "}"] +#[doc = r" ```"] +#[doc = r"
"] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct UintMinimumAndMaximumMinAndMax(u64); +impl ::std::ops::Deref for UintMinimumAndMaximumMinAndMax { + type Target = u64; + fn deref(&self) -> &u64 { + &self.0 + } +} +impl ::std::convert::From for u64 { + fn from(value: UintMinimumAndMaximumMinAndMax) -> Self { + value.0 + } +} +impl ::std::convert::TryFrom for UintMinimumAndMaximumMinAndMax { + type Error = self::error::ConversionError; + fn try_from(value: u64) -> ::std::result::Result { + if value < 1 { + return Err("value must be at least 1".into()); + } + if value > 256 { + return Err("value must be at most 256".into()); + } + Ok(Self(value)) + } +} +impl<'de> ::serde::Deserialize<'de> for UintMinimumAndMaximumMinAndMax { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + Self::try_from(::deserialize(deserializer)?) + .map_err(|e| ::custom(e.to_string())) + } +} #[doc = r" Types for composing complex structures."] pub mod builder { #[derive(Clone, Debug)] @@ -279,9 +371,10 @@ pub mod builder { } #[derive(Clone, Debug)] pub struct UintMinimumAndMaximum { - max: ::std::result::Result, + max: ::std::result::Result, min: ::std::result::Result, - min_and_max: ::std::result::Result<::std::num::NonZeroU64, ::std::string::String>, + min_and_max: + ::std::result::Result, min_non_zero: ::std::result::Result<::std::num::NonZeroU64, ::std::string::String>, min_uint_non_zero: ::std::result::Result<::std::num::NonZeroU64, ::std::string::String>, no_bounds: ::std::result::Result, @@ -301,7 +394,7 @@ pub mod builder { impl UintMinimumAndMaximum { pub fn max(mut self, value: T) -> Self where - T: ::std::convert::TryInto, + T: ::std::convert::TryInto, T::Error: ::std::fmt::Display, { self.max = value @@ -321,7 +414,7 @@ pub mod builder { } pub fn min_and_max(mut self, value: T) -> Self where - T: ::std::convert::TryInto<::std::num::NonZeroU64>, + T: ::std::convert::TryInto, T::Error: ::std::fmt::Display, { self.min_and_max = value diff --git a/typify/tests/schemas/types-with-more-impls.rs b/typify/tests/schemas/types-with-more-impls.rs index 6b2e3ddd..4818a28f 100644 --- a/typify/tests/schemas/types-with-more-impls.rs +++ b/typify/tests/schemas/types-with-more-impls.rs @@ -112,7 +112,7 @@ impl<'de> ::serde::Deserialize<'de> for PatternString { #[doc = "}"] #[doc = r" ```"] #[doc = r" "] -#[derive(:: serde :: Serialize, Clone, Debug)] +#[derive(:: serde :: Serialize, Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] #[serde(transparent)] pub struct Sub10Primes(u32); impl ::std::ops::Deref for Sub10Primes { From a7f1610e02ddc7df66ea605993b18f797bd0e6e4 Mon Sep 17 00:00:00 2001 From: Rain Date: Tue, 24 Feb 2026 12:11:14 -0800 Subject: [PATCH 2/2] rustfmt Created using spr 1.3.6-beta.1 --- typify-impl/src/convert.rs | 42 +++++++++----------------------------- 1 file changed, 10 insertions(+), 32 deletions(-) diff --git a/typify-impl/src/convert.rs b/typify-impl/src/convert.rs index 058938b0..c7aed97c 100644 --- a/typify-impl/src/convert.rs +++ b/typify-impl/src/convert.rs @@ -1001,10 +1001,7 @@ impl TypeSpace { if !v.is_finite() { return Err(Error::InvalidSchema { type_name: type_name.clone().into_option(), - reason: format!( - "non-finite bound value: {}", - v, - ), + reason: format!("non-finite bound value: {}", v,), }); } } @@ -1143,12 +1140,9 @@ impl TypeSpace { // needing to use `.get()` during bounds checks). // Convert fractional bounds to the tightest // integer range: ceil for min, floor for max. - let effective_min = min - .filter(|_| !min_is_exact) - .map(|v| v.ceil() as i128); - let effective_max = max - .filter(|_| !max_is_exact) - .map(|v| v.floor() as i128); + let effective_min = min.filter(|_| !min_is_exact).map(|v| v.ceil() as i128); + let effective_max = + max.filter(|_| !max_is_exact).map(|v| v.floor() as i128); // Rounding can invert bounds (e.g. min=0.5, // max=0.5 becomes 1, 0). if let (Some(lo), Some(hi)) = (effective_min, effective_max) { @@ -2591,12 +2585,7 @@ mod tests { #[test] fn test_non_finite_minimum() { - let result = try_convert_named_integer( - "Bad", - Some("uint8"), - Some(f64::NAN), - Some(63.0), - ); + let result = try_convert_named_integer("Bad", Some("uint8"), Some(f64::NAN), Some(63.0)); match result { Err(Error::InvalidSchema { type_name, reason }) => { assert_eq!(type_name.as_deref(), Some("Bad")); @@ -2608,12 +2597,8 @@ mod tests { #[test] fn test_non_finite_maximum() { - let result = try_convert_named_integer( - "Bad", - Some("uint8"), - Some(0.0), - Some(f64::INFINITY), - ); + let result = + try_convert_named_integer("Bad", Some("uint8"), Some(0.0), Some(f64::INFINITY)); match result { Err(Error::InvalidSchema { type_name, reason }) => { assert_eq!(type_name.as_deref(), Some("Bad")); @@ -2625,12 +2610,7 @@ mod tests { #[test] fn test_non_finite_negative_infinity() { - let result = try_convert_named_integer( - "Bad", - None, - Some(f64::NEG_INFINITY), - Some(10.0), - ); + let result = try_convert_named_integer("Bad", None, Some(f64::NEG_INFINITY), Some(10.0)); match result { Err(Error::InvalidSchema { type_name, reason }) => { assert_eq!(type_name.as_deref(), Some("Bad")); @@ -2644,8 +2624,7 @@ mod tests { fn test_fractional_bounds_invert_after_rounding_with_format() { // minimum=0.5, maximum=0.5 passes the f64 min>max check, but // after rounding (ceil for min, floor for max) becomes 1 > 0. - let result = - try_convert_named_integer("Bad", Some("uint8"), Some(0.5), Some(0.5)); + let result = try_convert_named_integer("Bad", Some("uint8"), Some(0.5), Some(0.5)); match result { Err(Error::InvalidSchema { type_name, reason }) => { assert_eq!(type_name.as_deref(), Some("Bad")); @@ -2684,8 +2663,7 @@ mod tests { #[test] fn test_fractional_bounds_invert_after_rounding_negative() { // Negative fractional: min=ceil(-0.5)=0, max=floor(-0.5)=-1. - let result = - try_convert_named_integer("Bad", Some("int8"), Some(-0.5), Some(-0.5)); + let result = try_convert_named_integer("Bad", Some("int8"), Some(-0.5), Some(-0.5)); match result { Err(Error::InvalidSchema { type_name, reason }) => { assert_eq!(type_name.as_deref(), Some("Bad"));