diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce43c0e..f797a5cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,12 @@ jobs: command: test args: --features valuable + - name: cargo test --features rust_decimal + uses: actions-rs/cargo@v1 + with: + command: test + args: --features rust_decimal + - name: cargo test --all-features uses: actions-rs/cargo@v1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 17121e65..a7cd2680 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ### v0.7.1 - Unreleased +- **[FEATURE]** Support `rust_decimal::Decimal` as an inner type behind the `rust_decimal` feature flag, with the standard numeric validators and sanitizers (see [#242](https://github.com/greyblake/nutype/issues/242)). - **[FEATURE]** Friendlier error when a `#[nutype(...)]` attribute is mistyped: suggests the closest match (e.g. `validte` -> `validate`) and lists the available nutype attributes (see [#240](https://github.com/greyblake/nutype/issues/240)). - **[FIX]** Fix misleading error for value-type mismatches in validators (see [#241](https://github.com/greyblake/nutype/issues/241)). diff --git a/Cargo.lock b/Cargo.lock index abdb53b1..72f19c98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -56,6 +67,12 @@ dependencies = [ "arbitrary", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "autocfg" version = "1.5.0" @@ -77,12 +94,88 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "cfg_attr_example" version = "0.1.0" @@ -116,6 +209,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "decimal_percentage" +version = "0.1.0" +dependencies = [ + "nutype", + "rust_decimal", + "rust_decimal_macros", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -124,7 +226,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -145,7 +247,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.115", ] [[package]] @@ -205,12 +307,62 @@ dependencies = [ "nutype", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -230,7 +382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -255,6 +407,18 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "kinded" version = "0.5.0" @@ -273,7 +437,7 @@ dependencies = [ "convert_case", "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -282,6 +446,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "memchr" version = "2.8.0" @@ -387,8 +557,9 @@ dependencies = [ "proc-macro2", "quote", "regex", + "rust_decimal", "rustc_version", - "syn", + "syn 2.0.115", "urlencoding", ] @@ -398,6 +569,30 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -407,6 +602,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quote" version = "1.0.44" @@ -416,6 +631,42 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -433,7 +684,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -465,6 +716,44 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rmp" version = "0.8.15" @@ -496,6 +785,34 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rust_decimal" +version = "1.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +dependencies = [ + "arbitrary", + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a5a6f027e892c7a035c6fddb50435a1fbf5a734ffc0c2a9fed4d0221440519" +dependencies = [ + "quote", + "syn 2.0.115", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -505,6 +822,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "schemars" version = "0.8.22" @@ -539,7 +862,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.115", ] [[package]] @@ -551,9 +874,15 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.115", ] +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "semver" version = "1.0.27" @@ -596,7 +925,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -607,7 +936,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -632,6 +961,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "str_reference" version = "0.1.0" @@ -663,6 +1004,17 @@ dependencies = [ "regex", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.115" @@ -674,6 +1026,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-triple" version = "1.0.0" @@ -703,6 +1061,8 @@ dependencies = [ "regex", "rmp-serde", "ron", + "rust_decimal", + "rust_decimal_macros", "schemars 0.8.22", "serde", "serde_json", @@ -728,9 +1088,24 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", ] +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "toml" version = "1.0.0+spec-1.1.0" @@ -755,6 +1130,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caee3f6e1c6f2025affe9191e6e6f66ade10b48f36b1a1b3cd92dfe405ffd260" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.0.7+spec-1.1.0" @@ -804,6 +1191,16 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -821,7 +1218,7 @@ checksum = "4e3a32a9bcc0f6c6ccfd5b27bcf298c58e753bcc9eeff268157a303393183a6d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -832,6 +1229,64 @@ dependencies = [ "valuable", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.115", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -861,6 +1316,38 @@ name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index b2b57946..f7fd5916 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,4 +27,5 @@ members = [ "examples/cfg_attr_example", "examples/valuable_example", "examples/str_reference", + "examples/decimal_percentage", ] diff --git a/Justfile b/Justfile index 1fa05b3b..0b3477d8 100644 --- a/Justfile +++ b/Justfile @@ -12,11 +12,13 @@ test-features: cargo test --features schemars08 cargo test --features arbitrary cargo test --features valuable + cargo test --features rust_decimal cargo test --all-features test-ui: cargo test --features ui + cargo test --features "ui rust_decimal arbitrary" test-doc: cd nutype && cargo test --doc --all-features diff --git a/README.md b/README.md index dd5644c3..58244647 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Nutype is a proc macro that allows adding extra constraints like _sanitization_ * [Quick start](#quick-start) -* [Inner types](#inner-types) ([String](#string) | [Integer](#integer) | [Float](#float) | [Other](#other-inner-types-and-generics)) +* [Inner types](#inner-types) ([String](#string) | [Integer](#integer) | [Float](#float) | [Decimal](#decimal) | [Other](#other-inner-types-and-generics)) * [Custom](#custom-sanitizers) ([sanitizers](#custom-sanitizers) | [validators](#custom-validators) | [errors](#custom-validation-with-a-custom-error-type)) * [Deriving traits](#deriving-traits) * [Constants](#constants) @@ -74,6 +74,7 @@ Available sanitizers, validators, and derivable traits are determined by the inn * String * Integer (`u8`, `u16`,`u32`, `u64`, `u128`, `i8`, `i16`, `i32`, `i64`, `i128`, `usize`, `isize`) * Float (`f32`, `f64`) +* Decimal (`rust_decimal::Decimal`, requires the `rust_decimal` feature) * Anything else ## String @@ -231,6 +232,67 @@ This can be done applying by `finite` validation. For example: struct Size(f64); ``` +## Decimal + +The `rust_decimal::Decimal` type is supported as an inner type behind the +`rust_decimal` feature flag. + +Requirements: +* The `rust_decimal` feature of `nutype` is enabled. +* You have to explicitly include `rust_decimal` as a dependency of your crate. +* For `derive(Arbitrary)` you also need `rust_decimal`'s `rust-fuzz` feature, and + for `derive(Serialize, Deserialize)` its `serde` feature. + +All three spellings of the inner type are detected: + +```rust +struct A(Decimal); // assumes `use rust_decimal::Decimal;` +struct B(rust_decimal::Decimal); +struct C(::rust_decimal::Decimal); +``` + +Example: + +```rust +use nutype::nutype; +use rust_decimal::Decimal; + +#[nutype( + validate(greater_or_equal = 0, less_or_equal = 100), // bare literals, no dec!() needed + derive(Debug, Clone, Copy, PartialEq, PartialOrd, Display), +)] +pub struct Percentage(Decimal); +``` + +Note the asymmetry: bounds in the attribute are written as bare literals (parsed +at compile time via `rust_decimal`'s `FromStr`), while values passed to +`try_new()` are real `Decimal`s, so you typically reach for `dec!(...)` or +`Decimal::from(...)` there. + +### Decimal sanitizers + +| Sanitizer | Description | Example | +|-----------|-------------------|----------------------------------------| +| `with` | Custom sanitizer. | `with = \|d: Decimal\| d.round_dp(2)` | + +### Decimal validators + +| Validator | Description | Error variant | Example | +| ------------------ | ------------------------------------ | ------------------------ | ------------------------ | +| `less` | Exclusive upper bound | `LessViolated` | `less = 100` | +| `less_or_equal` | Inclusive upper bound | `LessOrEqualViolated` | `less_or_equal = 100` | +| `greater` | Exclusive lower bound | `GreaterViolated` | `greater = 0` | +| `greater_or_equal` | Inclusive lower bound | `GreaterOrEqualViolated` | `greater_or_equal = 0` | +| `predicate` | Custom predicate | `PredicateViolated` | `predicate = \|d: &Decimal\| d.scale() <= 2` | +| `with` | Custom validator with a custom error | N/A | (see custom error section) | + + +### Decimal derivable traits + +The following traits can be derived for a decimal-based type: +`Debug`, `Clone`, `Copy`, `PartialEq`, `Eq`, `PartialOrd`, `Ord`, `FromStr`, `AsRef`, `Deref`, +`Into`, `From`, `TryFrom`, `Hash`, `Borrow`, `Display`, `Default`, `Serialize`, `Deserialize`, `Arbitrary`. + ## Other inner types and generics For any other type it is possible to define custom sanitizers with `with` and custom @@ -577,6 +639,7 @@ assert_eq!(name.into_inner(), " boo "); * `derive_unchecked` - enables `derive_unchecked` attribute to derive any arbitrary trait. * `new_unchecked` - enables generation of unsafe `::new_unchecked()` function. * `regex` - allows to use `regex = ` validation on string-based types. Note: your crate also has to explicitly have `regex` within its dependencies. +* `rust_decimal` - enables `rust_decimal::Decimal` as an inner type. Note: your crate also has to explicitly have `rust_decimal` within its dependencies. * `serde` - integrations with [`serde`](https://crates.io/crates/serde) crate. Allows to derive `Serialize` and `Deserialize` traits. * `schemars08` - allows to derive [`JsonSchema`](https://docs.rs/schemars/0.8.12/schemars/trait.JsonSchema.html) trait of [schemars](https://crates.io/crates/schemars) crate. Note that at the moment validation rules are not respected. * `std` - enabled by default. Use `default-features = false` to disable. diff --git a/examples/any_arbitrary/Cargo.toml b/examples/any_arbitrary/Cargo.toml index 66a5fd92..13ae6c14 100644 --- a/examples/any_arbitrary/Cargo.toml +++ b/examples/any_arbitrary/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "any_arbitrary" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/any_generics/Cargo.toml b/examples/any_generics/Cargo.toml index fa5f947d..45b99344 100644 --- a/examples/any_generics/Cargo.toml +++ b/examples/any_generics/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "any_generics" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/const_example/Cargo.toml b/examples/const_example/Cargo.toml index 166de844..67e04644 100644 --- a/examples/const_example/Cargo.toml +++ b/examples/const_example/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "const_example" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] nutype = { path = "../../nutype", features = ["new_unchecked"] } diff --git a/examples/custom_error/Cargo.toml b/examples/custom_error/Cargo.toml index 8911fda5..7d7bca49 100644 --- a/examples/custom_error/Cargo.toml +++ b/examples/custom_error/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "custom_error" version = "0.1.0" -edition = "2021" +edition = "2024" [dependencies] nutype = { path = "../../nutype" } diff --git a/examples/decimal_percentage/Cargo.toml b/examples/decimal_percentage/Cargo.toml new file mode 100644 index 00000000..cc2f4349 --- /dev/null +++ b/examples/decimal_percentage/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "decimal_percentage" +version = "0.1.0" +edition = "2024" +publish = false +authors = ["Serhii Potapov "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +nutype = { path = "../../nutype", features = ["rust_decimal"] } +rust_decimal = "1" +rust_decimal_macros = "1" diff --git a/examples/decimal_percentage/src/main.rs b/examples/decimal_percentage/src/main.rs new file mode 100644 index 00000000..ecac7ee1 --- /dev/null +++ b/examples/decimal_percentage/src/main.rs @@ -0,0 +1,56 @@ +use nutype::nutype; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; + +// A percentage in the inclusive range [0, 100]. +// Note: bounds in the attribute are written as bare literals (`0`, `100`), +// while values passed at runtime are real `Decimal`s (here via `dec!`). +#[nutype( + validate(greater_or_equal = 0, less_or_equal = 100), + derive(Debug, Clone, Copy, PartialEq, PartialOrd, Display) +)] +struct Percentage(Decimal); + +// A monetary amount, rounded to 2 decimal places by a sanitizer, that must be +// non-negative. +#[nutype( + sanitize(with = |d: Decimal| d.round_dp(2)), + validate(greater_or_equal = 0), + derive(Debug, Clone, Copy, PartialEq), +)] +struct Money(Decimal); + +fn main() { + // Valid percentages. + assert_eq!( + Percentage::try_new(dec!(42.5)).unwrap().into_inner(), + dec!(42.5) + ); + assert_eq!(Percentage::try_new(dec!(0)).unwrap().into_inner(), dec!(0)); + assert_eq!( + Percentage::try_new(dec!(100)).unwrap().into_inner(), + dec!(100) + ); + + // Out of range. + assert_eq!( + Percentage::try_new(dec!(150)), + Err(PercentageError::LessOrEqualViolated), + ); + assert_eq!( + Percentage::try_new(dec!(-1)), + Err(PercentageError::GreaterOrEqualViolated), + ); + + // The sanitizer rounds 9.999 -> 10.00 before validation. + assert_eq!( + Money::try_new(dec!(9.999)).unwrap().into_inner(), + dec!(10.00) + ); + assert_eq!( + Money::try_new(dec!(-0.01)), + Err(MoneyError::GreaterOrEqualViolated), + ); + + println!("All decimal examples passed."); +} diff --git a/examples/float_arbitrary/Cargo.toml b/examples/float_arbitrary/Cargo.toml index a74ae45d..6f83f4e7 100644 --- a/examples/float_arbitrary/Cargo.toml +++ b/examples/float_arbitrary/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "float_arbitrary" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/float_sortable/Cargo.toml b/examples/float_sortable/Cargo.toml index 584b3908..a306ef4a 100644 --- a/examples/float_sortable/Cargo.toml +++ b/examples/float_sortable/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "float_sortable" version = "0.1.0" -edition = "2021" +edition = "2024" publish = false authors = ["Serhii Potapov "] diff --git a/examples/integer_arbitrary/Cargo.toml b/examples/integer_arbitrary/Cargo.toml index 2fae226d..8f64702a 100644 --- a/examples/integer_arbitrary/Cargo.toml +++ b/examples/integer_arbitrary/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "integer_arbitrary" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/integer_bounded/Cargo.toml b/examples/integer_bounded/Cargo.toml index a1ea7ac7..a30fea81 100644 --- a/examples/integer_bounded/Cargo.toml +++ b/examples/integer_bounded/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "integer_bounded" version = "0.1.0" -edition = "2021" +edition = "2024" publish = false authors = ["Serhii Potapov "] diff --git a/examples/new_unchecked_example/Cargo.toml b/examples/new_unchecked_example/Cargo.toml index 25f5010a..b363ebcc 100644 --- a/examples/new_unchecked_example/Cargo.toml +++ b/examples/new_unchecked_example/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "new_unchecked_example" version = "0.1.0" -edition = "2021" +edition = "2024" publish = false authors = ["Serhii Potapov "] diff --git a/examples/no_std_example/Cargo.toml b/examples/no_std_example/Cargo.toml index e5fa6948..fef2eadd 100644 --- a/examples/no_std_example/Cargo.toml +++ b/examples/no_std_example/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "no_std_example" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/serde_complex/Cargo.toml b/examples/serde_complex/Cargo.toml index f724a54f..331891d5 100644 --- a/examples/serde_complex/Cargo.toml +++ b/examples/serde_complex/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "serde_complex" version = "0.1.0" -edition = "2021" +edition = "2024" publish = false authors = ["Serhii Potapov "] diff --git a/examples/string_arbitrary/Cargo.toml b/examples/string_arbitrary/Cargo.toml index 92a30176..68602ff8 100644 --- a/examples/string_arbitrary/Cargo.toml +++ b/examples/string_arbitrary/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "string_arbitrary" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/string_bounded_len/Cargo.toml b/examples/string_bounded_len/Cargo.toml index 9875392f..9315a733 100644 --- a/examples/string_bounded_len/Cargo.toml +++ b/examples/string_bounded_len/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "string_bounded_len" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/string_regex_email/Cargo.toml b/examples/string_regex_email/Cargo.toml index c7506e3e..cd67fa48 100644 --- a/examples/string_regex_email/Cargo.toml +++ b/examples/string_regex_email/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "string_regex_email" version = "0.1.0" -edition = "2021" +edition = "2024" publish = false authors = ["Serhii Potapov "] diff --git a/nutype/Cargo.toml b/nutype/Cargo.toml index 2280953e..57ebd346 100644 --- a/nutype/Cargo.toml +++ b/nutype/Cargo.toml @@ -32,4 +32,5 @@ schemars08 = ["nutype_macros/schemars08"] new_unchecked = ["nutype_macros/new_unchecked"] arbitrary = ["nutype_macros/arbitrary"] valuable = ["nutype_macros/valuable"] -derive_unchecked = ["nutype_macros/derive_unchecked"] \ No newline at end of file +derive_unchecked = ["nutype_macros/derive_unchecked"] +rust_decimal = ["nutype_macros/rust_decimal"] \ No newline at end of file diff --git a/nutype_macros/Cargo.toml b/nutype_macros/Cargo.toml index 76a4d86c..41d5569e 100644 --- a/nutype_macros/Cargo.toml +++ b/nutype_macros/Cargo.toml @@ -22,6 +22,11 @@ syn = { version = "2.0", features = ["extra-traits", "full"] } # regex is used at compile-time to verify that a string literal provided # as `regex = "^foo|bar$"` can be compiled to a Regex without errors. regex = { version = "1", optional = true } +# rust_decimal is used at compile-time to parse decimal literals provided as +# bound values (e.g. `greater = 0.5`) via its `FromStr` implementation, and to +# emit them back losslessly. It is only pulled in when the `rust_decimal` +# feature is enabled. +rust_decimal = { version = "1", default-features = false, optional = true } cfg-if = "1.0" kinded = "0.5" urlencoding = "2.0" @@ -43,3 +48,4 @@ new_unchecked = [] arbitrary = [] valuable = [] derive_unchecked = [] +rust_decimal = ["dep:rust_decimal"] diff --git a/nutype_macros/src/common/models.rs b/nutype_macros/src/common/models.rs index 45baa4eb..312cb194 100644 --- a/nutype_macros/src/common/models.rs +++ b/nutype_macros/src/common/models.rs @@ -12,6 +12,8 @@ use syn::{ parse::{Parse, ParseStream}, }; +#[cfg(feature = "rust_decimal")] +use crate::decimal::models::DecimalInnerType; use crate::{ any::models::AnyInnerType, float::models::FloatInnerType, integer::models::IntegerInnerType, string::models::StringInnerType, @@ -55,6 +57,8 @@ pub enum InnerType { String(StringInnerType), Integer(IntegerInnerType), Float(FloatInnerType), + #[cfg(feature = "rust_decimal")] + Decimal(DecimalInnerType), Any(AnyInnerType), } @@ -64,6 +68,20 @@ impl From for InnerType { } } +#[cfg(feature = "rust_decimal")] +impl From for InnerType { + fn from(tp: DecimalInnerType) -> InnerType { + InnerType::Decimal(tp) + } +} + +#[cfg(feature = "rust_decimal")] +impl From<&DecimalInnerType> for InnerType { + fn from(tp: &DecimalInnerType) -> InnerType { + InnerType::Decimal(*tp) + } +} + impl From<&IntegerInnerType> for InnerType { fn from(tp: &IntegerInnerType) -> InnerType { InnerType::Integer(*tp) @@ -112,6 +130,10 @@ impl ToTokens for InnerType { InnerType::Float(float_type) => { float_type.to_tokens(token_stream); } + #[cfg(feature = "rust_decimal")] + InnerType::Decimal(decimal_type) => { + decimal_type.to_tokens(token_stream); + } InnerType::Any(any_type) => { any_type.to_tokens(token_stream); } diff --git a/nutype_macros/src/common/parse/meta.rs b/nutype_macros/src/common/parse/meta.rs index 86df62db..435d1710 100644 --- a/nutype_macros/src/common/parse/meta.rs +++ b/nutype_macros/src/common/parse/meta.rs @@ -67,7 +67,15 @@ pub fn parse_meta(token_stream: TokenStream) -> Result { let type_path_str = seg.ty.clone().into_token_stream().to_string(); - let inner_type = match type_path_str.as_ref() { + // `into_token_stream().to_string()` renders paths with spaces around `::` + // (e.g. `"rust_decimal :: Decimal"`), and that spacing is brittle across + // syn versions. Strip all whitespace before matching the compact forms. + let compact_type_path: String = type_path_str + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + let inner_type = match compact_type_path.as_str() { "String" => InnerType::String(StringInnerType), "u8" => InnerType::Integer(IntegerInnerType::U8), "u16" => InnerType::Integer(IntegerInnerType::U16), @@ -83,6 +91,23 @@ pub fn parse_meta(token_stream: TokenStream) -> Result { "isize" => InnerType::Integer(IntegerInnerType::Isize), "f32" => InnerType::Float(FloatInnerType::F32), "f64" => InnerType::Float(FloatInnerType::F64), + "Decimal" | "rust_decimal::Decimal" | "::rust_decimal::Decimal" => { + #[cfg(feature = "rust_decimal")] + { + InnerType::Decimal(crate::decimal::models::DecimalInnerType) + } + #[cfg(not(feature = "rust_decimal"))] + { + return Err(syn::Error::new( + seg.ty.span(), + concat!( + "To use `Decimal` as the inner type, enable the `rust_decimal` feature of crate `nutype`, e.g.\n\n", + " nutype = { version = \"0.7\", features = [\"rust_decimal\"] }\n\n", + "You also need to add `rust_decimal` as a dependency of your crate." + ), + )); + } + } _ => InnerType::Any(AnyInnerType::new(seg.clone())), }; diff --git a/nutype_macros/src/decimal/generate/error.rs b/nutype_macros/src/decimal/generate/error.rs new file mode 100644 index 00000000..56ab22b4 --- /dev/null +++ b/nutype_macros/src/decimal/generate/error.rs @@ -0,0 +1,93 @@ +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; + +use super::super::models::DecimalValidator; +use crate::common::{ + generate::error::gen_impl_error_trait, + models::{ErrorTypePath, TypeName}, +}; + +pub fn gen_validation_error_type( + type_name: &TypeName, + error_type_path: &ErrorTypePath, + validators: &[DecimalValidator], +) -> TokenStream { + let definition = gen_definition(error_type_path, validators); + let impl_display_trait = gen_impl_display_trait(type_name, error_type_path, validators); + let impl_error_trait = gen_impl_error_trait(error_type_path); + + quote! { + #[derive(Debug, Clone, PartialEq, Eq)] + #definition + + #impl_display_trait + #impl_error_trait + } +} + +fn gen_definition( + error_type_path: &ErrorTypePath, + validators: &[DecimalValidator], +) -> TokenStream { + let error_variants: TokenStream = validators + .iter() + .map(|validator| match validator { + DecimalValidator::Greater(_) => { + quote!(GreaterViolated,) + } + DecimalValidator::GreaterOrEqual(_) => { + quote!(GreaterOrEqualViolated,) + } + DecimalValidator::Less(_) => { + quote!(LessViolated,) + } + DecimalValidator::LessOrEqual(_) => { + quote!(LessOrEqualViolated,) + } + DecimalValidator::Predicate(_) => { + quote!(PredicateViolated,) + } + }) + .collect(); + + quote! { + #[allow(clippy::enum_variant_names)] + pub enum #error_type_path { + #error_variants + } + } +} + +fn gen_impl_display_trait( + type_name: &TypeName, + error_type_path: &ErrorTypePath, + validators: &[DecimalValidator], +) -> TokenStream { + let match_arms = validators.iter().map(|validator| match validator { + DecimalValidator::Greater(val) => quote! { + #error_type_path::GreaterViolated => write!(f, "{} is too small. The value must be greater than {:#?}.", stringify!(#type_name), #val) + }, + DecimalValidator::GreaterOrEqual(val) => quote! { + #error_type_path::GreaterOrEqualViolated => write!(f, "{} is too small. The value must be greater or equal to {:#?}.", stringify!(#type_name), #val) + }, + DecimalValidator::Less(val) => quote! { + #error_type_path::LessViolated=> write!(f, "{} is too big. The value must be less than {:#?}.", stringify!(#type_name), #val) + }, + DecimalValidator::LessOrEqual(val) => quote! { + #error_type_path::LessOrEqualViolated=> write!(f, "{} is too big. The value must be less or equal to {:#?}.", stringify!(#type_name), #val) + }, + DecimalValidator::Predicate(_) => quote! { + #error_type_path::PredicateViolated => write!(f, "{} failed the predicate test.", stringify!(#type_name)) + }, + }); + + quote! { + impl ::core::fmt::Display for #error_type_path { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + match self { + #(#match_arms,)* + } + } + } + } +} diff --git a/nutype_macros/src/decimal/generate/mod.rs b/nutype_macros/src/decimal/generate/mod.rs new file mode 100644 index 00000000..428a12e4 --- /dev/null +++ b/nutype_macros/src/decimal/generate/mod.rs @@ -0,0 +1,178 @@ +pub mod error; +pub mod traits; + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; +use syn::Generics; + +use self::{error::gen_validation_error_type, traits::gen_traits}; +use super::{ + DecimalNewtype, + models::{ + DecimalDeriveTrait, DecimalGuard, DecimalInnerType, DecimalSanitizer, DecimalType, + DecimalValidator, + }, +}; +use crate::common::{ + generate::{ + GenerateNewtype, + tests::{ + gen_test_should_have_consistent_lower_and_upper_boundaries, + gen_test_should_have_valid_default_value, + }, + traits::GeneratedTraits, + }, + models::{ + ConditionalDeriveGroup, ConstFn, ErrorTypePath, Guard, SpannedDeriveUnsafeTrait, TypeName, + }, +}; + +impl GenerateNewtype for DecimalNewtype +where + T: DecimalType + ToTokens + PartialOrd, +{ + type Sanitizer = DecimalSanitizer; + type Validator = DecimalValidator; + type InnerType = DecimalInnerType; + type TypedTrait = DecimalDeriveTrait; + + fn gen_fn_sanitize( + inner_type: &Self::InnerType, + sanitizers: &[Self::Sanitizer], + const_fn: ConstFn, + ) -> TokenStream { + let transformations: TokenStream = sanitizers + .iter() + .map(|san| match san { + DecimalSanitizer::With(custom_sanitizer) => { + quote!( + value = (#custom_sanitizer)(value); + ) + } + DecimalSanitizer::_Phantom(_) => { + unreachable!("decimal::gen: DecimalSanitizer::_Phantom must not be used") + } + }) + .collect(); + + quote!( + #const_fn fn __sanitize__(mut value: #inner_type) -> #inner_type { + #transformations + value + } + ) + } + + fn gen_fn_validate( + inner_type: &Self::InnerType, + error_type_path: &ErrorTypePath, + validators: &[Self::Validator], + const_fn: ConstFn, + ) -> TokenStream { + let validations: TokenStream = validators + .iter() + .map(|validator| match validator { + DecimalValidator::Less(exclusive_upper_bound) => { + quote!( + if val >= #exclusive_upper_bound { + return Err(#error_type_path::LessViolated); + } + ) + } + DecimalValidator::LessOrEqual(max) => { + quote!( + if val > #max { + return Err(#error_type_path::LessOrEqualViolated); + } + ) + } + DecimalValidator::Greater(exclusive_lower_bound) => { + quote!( + if val <= #exclusive_lower_bound { + return Err(#error_type_path::GreaterViolated); + } + ) + } + DecimalValidator::GreaterOrEqual(min) => { + quote!( + if val < #min { + return Err(#error_type_path::GreaterOrEqualViolated); + } + ) + } + DecimalValidator::Predicate(custom_is_valid_fn) => { + quote!( + if !(#custom_is_valid_fn)(&val) { + return Err(#error_type_path::PredicateViolated); + } + ) + } + }) + .collect(); + + quote!( + #const_fn fn __validate__(val: &#inner_type) -> ::core::result::Result<(), #error_type_path> { + let val = *val; + #validations + Ok(()) + } + ) + } + + fn gen_validation_error_type( + type_name: &TypeName, + error_type_path: &ErrorTypePath, + validators: &[Self::Validator], + ) -> TokenStream { + gen_validation_error_type(type_name, error_type_path, validators) + } + + fn gen_traits( + type_name: &TypeName, + generics: &Generics, + inner_type: &Self::InnerType, + traits: HashSet, + unsafe_traits: &[SpannedDeriveUnsafeTrait], + maybe_default_value: Option, + guard: &DecimalGuard, + conditional_derives: &[ConditionalDeriveGroup], + ) -> Result { + gen_traits( + type_name, + generics, + inner_type, + traits, + unsafe_traits, + maybe_default_value, + guard, + conditional_derives, + ) + } + + fn gen_tests( + type_name: &TypeName, + generics: &Generics, + _inner_type: &Self::InnerType, + maybe_default_value: &Option, + guard: &Guard, + _traits: &HashSet, + ) -> TokenStream { + let test_lower_vs_upper = guard.standard_validators().and_then(|validators| { + gen_test_should_have_consistent_lower_and_upper_boundaries(type_name, validators) + }); + + let test_valid_default_value = gen_test_should_have_valid_default_value( + type_name, + generics, + maybe_default_value, + guard.has_validation(), + ); + + quote! { + #test_lower_vs_upper + #test_valid_default_value + } + } +} diff --git a/nutype_macros/src/decimal/generate/traits/arbitrary.rs b/nutype_macros/src/decimal/generate/traits/arbitrary.rs new file mode 100644 index 00000000..aabb217f --- /dev/null +++ b/nutype_macros/src/decimal/generate/traits/arbitrary.rs @@ -0,0 +1,175 @@ +use proc_macro2::{Span, TokenStream}; +use quote::{ToTokens, quote}; + +use crate::{ + common::models::{TypeName, Validation}, + decimal::models::{DecimalGuard, DecimalInnerType, DecimalValidator}, + utils::issue_reporter::{Issue, build_github_link_with_issue}, +}; + +pub fn gen_impl_trait_arbitrary( + type_name: &TypeName, + inner_type: &DecimalInnerType, + guard: &DecimalGuard, +) -> Result { + let GeneratedInnerValue { + body: generate_inner_value, + consumed_bytes, + } = gen_generate_valid_inner_value(inner_type, guard)?; + + let construct_value = if guard.has_validation() { + // If by some reason we generate an invalid value, make it very easy for the user to report + let report_issue_msg = + build_github_link_with_issue(&Issue::ArbitraryGeneratedInvalidValue { + inner_type: inner_type.to_string(), + }); + let error_text = + format!("Arbitrary generated an invalid value for {type_name}.\n\n{report_issue_msg}"); + quote!( + Self::try_new(inner_value).expect(#error_text) + ) + } else { + quote!(Self::new(inner_value)) + }; + + Ok(quote!( + impl ::arbitrary::Arbitrary<'_> for #type_name { + fn arbitrary(u: &mut ::arbitrary::Unstructured<'_>) -> ::arbitrary::Result { + let inner_value: #inner_type = { + #generate_inner_value + }; + Ok(#construct_value) + } + + #[inline] + fn size_hint(_depth: usize) -> (usize, Option) { + // Mirror exactly what `arbitrary` draws above: the two-bound case + // consumes a single `u64`, every other case a single `Decimal`. + let n = #consumed_bytes; + (n, Some(n)) + } + } + )) +} + +/// The generated code that produces a valid inner value, together with a token +/// stream that evaluates to the number of bytes it draws from `Unstructured`. +/// The latter feeds `size_hint`, so the two must stay in sync. +struct GeneratedInnerValue { + body: TokenStream, + consumed_bytes: TokenStream, +} + +/// Generates code that produces a valid inner `Decimal` value. +fn gen_generate_valid_inner_value( + inner_type: &DecimalInnerType, + guard: &DecimalGuard, +) -> Result { + match guard { + DecimalGuard::WithoutValidation { .. } => { + // No validation: delegate straight to the inner type's Arbitrary impl + // (requires the user to enable `rust_decimal/rust-fuzz`). + Ok(GeneratedInnerValue { + body: quote!(u.arbitrary()?), + consumed_bytes: quote!(::core::mem::size_of::<#inner_type>()), + }) + } + DecimalGuard::WithValidation { + sanitizers, + validation, + } => match validation { + Validation::Standard { validators, .. } => { + if !sanitizers.is_empty() { + let msg = "It's not possible to derive `Arbitrary` for a Decimal type that has both a `with` sanitizer and validation.\nYou have to implement `Arbitrary` on your own."; + return Err(syn::Error::new(Span::call_site(), msg)); + } + gen_generate_valid_inner_value_with_validators(inner_type, validators) + } + Validation::Custom { .. } => { + let msg = "It's not possible to derive `Arbitrary` for a Decimal type with custom validation.\nYou have to implement `Arbitrary` on your own."; + Err(syn::Error::new(Span::call_site(), msg)) + } + }, + } +} + +fn gen_generate_valid_inner_value_with_validators( + inner_type: &DecimalInnerType, + validators: &[DecimalValidator], +) -> Result { + let mut lower: Option = None; + let mut upper: Option = None; + + for validator in validators { + match validator { + DecimalValidator::Predicate(_) => { + let msg = "It's not possible to derive `Arbitrary` for a Decimal type with a `predicate` validator.\nYou have to implement `Arbitrary` on your own."; + return Err(syn::Error::new(Span::call_site(), msg)); + } + DecimalValidator::Greater(_) | DecimalValidator::Less(_) => { + let msg = "Deriving `Arbitrary` for a Decimal type currently supports inclusive bounds only (`greater_or_equal` / `less_or_equal`).\nExclusive bounds (`greater` / `less`) are not yet supported, because nudging an exact Decimal strictly past a bound is unsolved here."; + return Err(syn::Error::new(Span::call_site(), msg)); + } + DecimalValidator::GreaterOrEqual(value) => { + lower = Some(value.to_token_stream()); + } + DecimalValidator::LessOrEqual(value) => { + upper = Some(value.to_token_stream()); + } + } + } + + let decimal_bytes = quote!(::core::mem::size_of::<#inner_type>()); + + let generated = match (lower, upper) { + (Some(lower), Some(upper)) => GeneratedInnerValue { + body: quote!( + let lower: ::rust_decimal::Decimal = #lower; + let upper: ::rust_decimal::Decimal = #upper; + // Build a fraction in [0, 1] (u64::MAX fits comfortably in Decimal's range). + let numerator = ::rust_decimal::Decimal::from(::arbitrary(u)?); + let denominator = ::rust_decimal::Decimal::from(u64::MAX); + let fraction = numerator + .checked_div(denominator) + .unwrap_or(::rust_decimal::Decimal::ZERO); + // Scale into [lower, upper], using checked arithmetic and falling back + // to `lower` (always valid) on overflow, then clamp to be safe. + let value = upper + .checked_sub(lower) + .and_then(|span| fraction.checked_mul(span)) + .and_then(|delta| lower.checked_add(delta)) + .unwrap_or(lower); + ::core::cmp::Ord::clamp(value, lower, upper) + ), + // Only a single `u64` is drawn in this branch. + consumed_bytes: quote!(::core::mem::size_of::()), + }, + (Some(lower), None) => GeneratedInnerValue { + // Best-effort: `lower + |base|` is always `>= lower`; on overflow we fall + // back to exactly `lower` (still valid). The distribution is therefore + // whatever the inner `Arbitrary` produces, biased towards `lower`. + body: quote!( + let lower: ::rust_decimal::Decimal = #lower; + let base: ::rust_decimal::Decimal = u.arbitrary()?; + lower.checked_add(base.abs()).unwrap_or(lower) + ), + consumed_bytes: decimal_bytes.clone(), + }, + (None, Some(upper)) => GeneratedInnerValue { + // Best-effort: `upper - |base|` is always `<= upper`; on overflow we fall + // back to exactly `upper` (still valid). + body: quote!( + let upper: ::rust_decimal::Decimal = #upper; + let base: ::rust_decimal::Decimal = u.arbitrary()?; + upper.checked_sub(base.abs()).unwrap_or(upper) + ), + consumed_bytes: decimal_bytes.clone(), + }, + (None, None) => GeneratedInnerValue { + body: quote!(u.arbitrary()?), + consumed_bytes: decimal_bytes, + }, + }; + + Ok(generated) +} diff --git a/nutype_macros/src/decimal/generate/traits/mod.rs b/nutype_macros/src/decimal/generate/traits/mod.rs new file mode 100644 index 00000000..a9f45eb4 --- /dev/null +++ b/nutype_macros/src/decimal/generate/traits/mod.rs @@ -0,0 +1,253 @@ +mod arbitrary; + +use std::collections::HashSet; + +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; +use syn::Generics; + +use crate::{ + common::{ + generate::traits::{ + ConditionalTraits, GeneratableTrait, GeneratableTraits, GeneratedTraits, + HasGeneratedParseError, gen_impl_trait_as_ref, gen_impl_trait_borrow, + gen_impl_trait_default, gen_impl_trait_deref, gen_impl_trait_display, + gen_impl_trait_from, gen_impl_trait_from_str, gen_impl_trait_into, + gen_impl_trait_serde_deserialize, gen_impl_trait_serde_serialize, + gen_impl_trait_try_from, process_conditional_derives, split_into_generatable_traits, + }, + models::{ConditionalDeriveGroup, SpannedDeriveUnsafeTrait, TypeName}, + }, + decimal::models::{DecimalDeriveTrait, DecimalGuard, DecimalInnerType}, +}; + +type DecimalGeneratableTrait = GeneratableTrait; + +#[allow(clippy::too_many_arguments)] +pub fn gen_traits( + type_name: &TypeName, + generics: &Generics, + inner_type: &DecimalInnerType, + traits: HashSet, + unsafe_traits: &[SpannedDeriveUnsafeTrait], + maybe_default_value: Option, + guard: &DecimalGuard, + conditional_derives: &[ConditionalDeriveGroup], +) -> Result { + let GeneratableTraits { + transparent_traits, + irregular_traits, + } = split_into_generatable_traits(traits); + + let derive_transparent_traits = quote! { + #[derive( + #(#transparent_traits,)* + #(#unsafe_traits,)* + )] + }; + + let implement_traits = gen_implemented_traits( + type_name, + generics, + inner_type, + irregular_traits, + maybe_default_value.clone(), + guard, + )?; + + let ConditionalTraits { + derive_transparent_traits: conditional_derive_transparent_traits, + implement_traits: conditional_implement_traits, + from_str_parse_errors: conditional_from_str_parse_errors, + } = process_conditional_derives(conditional_derives, type_name, |irregular| { + gen_implemented_traits( + type_name, + generics, + inner_type, + irregular, + maybe_default_value.clone(), + guard, + ) + })?; + + Ok(GeneratedTraits { + derive_transparent_traits, + implement_traits, + conditional_derive_transparent_traits, + conditional_implement_traits, + conditional_from_str_parse_errors, + }) +} + +impl From for DecimalGeneratableTrait { + fn from(derive_trait: DecimalDeriveTrait) -> DecimalGeneratableTrait { + match derive_trait { + DecimalDeriveTrait::Debug => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::Debug) + } + DecimalDeriveTrait::Clone => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::Clone) + } + DecimalDeriveTrait::Copy => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::Copy) + } + DecimalDeriveTrait::PartialEq => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::PartialEq) + } + DecimalDeriveTrait::Eq => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::Eq) + } + DecimalDeriveTrait::PartialOrd => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::PartialOrd) + } + DecimalDeriveTrait::Ord => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::Ord) + } + DecimalDeriveTrait::Hash => { + DecimalGeneratableTrait::Transparent(DecimalTransparentTrait::Hash) + } + DecimalDeriveTrait::FromStr => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::FromStr) + } + DecimalDeriveTrait::AsRef => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::AsRef) + } + DecimalDeriveTrait::Deref => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::Deref) + } + DecimalDeriveTrait::Into => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::Into) + } + DecimalDeriveTrait::From => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::From) + } + DecimalDeriveTrait::TryFrom => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::TryFrom) + } + DecimalDeriveTrait::Borrow => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::Borrow) + } + DecimalDeriveTrait::Display => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::Display) + } + DecimalDeriveTrait::Default => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::Default) + } + DecimalDeriveTrait::SerdeSerialize => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::SerdeSerialize) + } + DecimalDeriveTrait::SerdeDeserialize => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::SerdeDeserialize) + } + DecimalDeriveTrait::ArbitraryArbitrary => { + DecimalGeneratableTrait::Irregular(DecimalIrregularTrait::ArbitraryArbitrary) + } + } + } +} + +/// A trait that can be automatically derived. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +enum DecimalTransparentTrait { + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, +} + +/// A trait that can not be automatically derived and we need to generate +/// an implementation for it. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] +enum DecimalIrregularTrait { + FromStr, + AsRef, + Deref, + From, + TryFrom, + Borrow, + Into, + Display, + Default, + SerdeSerialize, + SerdeDeserialize, + ArbitraryArbitrary, +} + +/// Decimal's `FromStr` generates a `ParseError` type via `gen_impl_trait_from_str` -> +/// `gen_def_parse_error`, which needs module-level re-export in conditional derives. +impl HasGeneratedParseError for DecimalIrregularTrait { + fn has_generated_parse_error(&self) -> bool { + matches!(self, Self::FromStr) + } +} + +impl ToTokens for DecimalTransparentTrait { + fn to_tokens(&self, token_stream: &mut TokenStream) { + let tokens = match self { + Self::Debug => quote!(Debug), + Self::Clone => quote!(Clone), + Self::Copy => quote!(Copy), + Self::PartialEq => quote!(PartialEq), + Self::Eq => quote!(Eq), + Self::PartialOrd => quote!(PartialOrd), + Self::Ord => quote!(Ord), + Self::Hash => quote!(Hash), + }; + tokens.to_tokens(token_stream) + } +} + +fn gen_implemented_traits( + type_name: &TypeName, + generics: &Generics, + inner_type: &DecimalInnerType, + impl_traits: Vec, + maybe_default_value: Option, + guard: &DecimalGuard, +) -> Result { + let maybe_error_type_name = guard.maybe_error_type_path(); + impl_traits + .iter() + .map(|t| match t { + DecimalIrregularTrait::AsRef => Ok(gen_impl_trait_as_ref(type_name, generics, inner_type)), + DecimalIrregularTrait::Deref => Ok(gen_impl_trait_deref(type_name, generics, inner_type)), + DecimalIrregularTrait::FromStr => { + Ok(gen_impl_trait_from_str(type_name, generics, inner_type, maybe_error_type_name)) + } + DecimalIrregularTrait::From => Ok(gen_impl_trait_from(type_name, generics, inner_type)), + DecimalIrregularTrait::Into => Ok(gen_impl_trait_into(type_name, generics, inner_type)), + DecimalIrregularTrait::TryFrom => { + Ok(gen_impl_trait_try_from(type_name, generics, inner_type, maybe_error_type_name)) + } + DecimalIrregularTrait::Borrow => Ok(gen_impl_trait_borrow(type_name, generics, inner_type)), + DecimalIrregularTrait::Display => Ok(gen_impl_trait_display(type_name, generics)), + DecimalIrregularTrait::Default => { + match maybe_default_value { + Some(ref default_value) => { + let has_validation = maybe_error_type_name.is_some(); + Ok(gen_impl_trait_default(type_name, generics, default_value, has_validation)) + }, + None => { + let span = proc_macro2::Span::call_site(); + let msg = format!("Trait `Default` is derived for type {type_name}, but `default = ` parameter is missing in #[nutype] macro"); + Err(syn::Error::new(span, msg)) + } + } + } + DecimalIrregularTrait::SerdeSerialize => Ok(gen_impl_trait_serde_serialize(type_name, generics)), + DecimalIrregularTrait::SerdeDeserialize => Ok(gen_impl_trait_serde_deserialize( + type_name, + generics, + inner_type, + maybe_error_type_name, + )), + DecimalIrregularTrait::ArbitraryArbitrary => { + arbitrary::gen_impl_trait_arbitrary(type_name, inner_type, guard) + } + }) + .collect() +} diff --git a/nutype_macros/src/decimal/mod.rs b/nutype_macros/src/decimal/mod.rs new file mode 100644 index 00000000..21b6e8d0 --- /dev/null +++ b/nutype_macros/src/decimal/mod.rs @@ -0,0 +1,76 @@ +use core::{ + fmt::{Debug, Display}, + marker::PhantomData, + str::FromStr, +}; + +use proc_macro2::TokenStream; +use quote::ToTokens; + +use crate::common::{ + generate::GenerateNewtype, + models::{ + Attributes, CfgAttrEntry, GenerateParams, Guard, Newtype, SpannedDeriveTrait, TypeName, + ValidatedDerives, + }, +}; + +use self::{ + models::{ + DecimalDeriveTrait, DecimalGuard, DecimalInnerType, DecimalSanitizer, DecimalType, + DecimalValidator, + }, + validate::validate_decimal_derive_traits, +}; + +pub mod generate; +pub mod models; +pub mod parse; +pub mod validate; + +pub struct DecimalNewtype(PhantomData); + +impl Newtype for DecimalNewtype +where + T: DecimalType + ToTokens + FromStr + PartialOrd + Clone, + ::Err: Debug + Display, +{ + type Sanitizer = DecimalSanitizer; + type Validator = DecimalValidator; + type TypedTrait = DecimalDeriveTrait; + type InnerType = DecimalInnerType; + + fn parse_attributes( + attrs: TokenStream, + type_name: &TypeName, + ) -> Result, SpannedDeriveTrait>, syn::Error> { + parse::parse_attributes::(attrs, type_name) + } + + fn validate( + guard: &Guard, + derive_traits: Vec, + cfg_attr_entries: &[CfgAttrEntry], + maybe_default_value: &Option, + type_name: &TypeName, + ) -> Result, syn::Error> { + let has_validation = guard.has_validation(); + validate_decimal_derive_traits( + derive_traits, + has_validation, + cfg_attr_entries, + maybe_default_value, + type_name, + ) + } + + fn generate( + params: GenerateParams< + DecimalInnerType, + Self::TypedTrait, + Guard, + >, + ) -> Result { + DecimalNewtype::gen_nutype(params) + } +} diff --git a/nutype_macros/src/decimal/models.rs b/nutype_macros/src/decimal/models.rs new file mode 100644 index 00000000..d5e8f546 --- /dev/null +++ b/nutype_macros/src/decimal/models.rs @@ -0,0 +1,219 @@ +use core::{cmp::Ordering, str::FromStr}; + +use kinded::Kinded; +use proc_macro2::TokenStream; +use quote::{ToTokens, quote}; + +use crate::common::models::{ + Guard, RawGuard, SpannedItem, TypeTrait, TypedCustomFunction, ValueOrExpr, + impl_numeric_bound_on_vec_of, impl_numeric_bound_validator, +}; + +// Literal value +// + +/// A decimal literal value parsed at compile time. +/// +/// This is a thin wrapper around a real `rust_decimal::Decimal`. The wrapper +/// exists only to satisfy the orphan rule for `ToTokens` (we cannot +/// `impl ToTokens for rust_decimal::Decimal`). All parsing and comparison +/// delegate to `Decimal`. +#[derive(Debug, Clone)] +pub struct DecimalLit(rust_decimal::Decimal); + +impl FromStr for DecimalLit { + type Err = rust_decimal::Error; + + fn from_str(s: &str) -> Result { + // Delegate to rust_decimal's own parser: we accept exactly what it + // accepts (after the syn `Lit` layer has already lexed the literal). + rust_decimal::Decimal::from_str(s).map(DecimalLit) + } +} + +impl PartialEq for DecimalLit { + fn eq(&self, other: &Self) -> bool { + // `Decimal` already treats `0.5 == 0.50` as equal. + self.0 == other.0 + } +} + +impl Eq for DecimalLit {} + +impl PartialOrd for DecimalLit { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DecimalLit { + fn cmp(&self, other: &Self) -> Ordering { + self.0.cmp(&other.0) + } +} + +impl ToTokens for DecimalLit { + fn to_tokens(&self, token_stream: &mut TokenStream) { + // Reconstruct the value losslessly from its components. `mantissa()` + // and `scale()` of an existing `Decimal` are always in range, so + // `from_i128_with_scale` never panics here. + let mantissa: i128 = self.0.mantissa(); + let scale: u32 = self.0.scale(); + quote!( + ::rust_decimal::Decimal::from_i128_with_scale(#mantissa, #scale) + ) + .to_tokens(token_stream); + } +} + +/// Marker trait, mirroring `IntegerType`/`FloatType`. There is exactly one +/// decimal value type (`DecimalLit`), but keeping the newtype generic over `T` +/// lets us reuse the same machinery as integers and floats. +pub trait DecimalType {} + +impl DecimalType for DecimalLit {} + +// Inner type +// + +/// The inner type of a decimal-based newtype: `rust_decimal::Decimal`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DecimalInnerType; + +impl ToTokens for DecimalInnerType { + fn to_tokens(&self, token_stream: &mut TokenStream) { + quote!(::rust_decimal::Decimal).to_tokens(token_stream); + } +} + +impl ::core::fmt::Display for DecimalInnerType { + fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result { + "rust_decimal::Decimal".fmt(f) + } +} + +// Sanitizer +// + +#[derive(Debug, Kinded)] +#[kinded(display = "snake_case")] +pub enum DecimalSanitizer { + With(TypedCustomFunction), + _Phantom(core::marker::PhantomData), +} + +pub type SpannedDecimalSanitizer = SpannedItem>; + +// Validator +// + +#[derive(Debug, Kinded)] +#[kinded(display = "snake_case")] +pub enum DecimalValidator { + Greater(ValueOrExpr), + GreaterOrEqual(ValueOrExpr), + Less(ValueOrExpr), + LessOrEqual(ValueOrExpr), + Predicate(TypedCustomFunction), +} + +impl_numeric_bound_validator!(DecimalValidator); +impl_numeric_bound_on_vec_of!(DecimalValidator); + +pub type SpannedDecimalValidator = SpannedItem>; + +// Traits +// +// Same set as `IntegerDeriveTrait`, with three deliberate differences: +// * no `SchemarsJsonSchema` (deferred), +// * no `ValuableValuable` (`rust_decimal::Decimal` does not implement `Valuable`), +// * `ArbitraryArbitrary` is supported (requires the user to enable +// `rust_decimal/rust-fuzz`). +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)] +pub enum DecimalDeriveTrait { + // Standard + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + FromStr, + AsRef, + Into, + From, + TryFrom, + Hash, + Borrow, + Display, + Default, + Deref, + + // External crates + SerdeSerialize, + SerdeDeserialize, + ArbitraryArbitrary, +} + +impl TypeTrait for DecimalDeriveTrait { + fn is_from_str(&self) -> bool { + self == &DecimalDeriveTrait::FromStr + } + fn is_default(&self) -> bool { + self == &DecimalDeriveTrait::Default + } +} + +pub type DecimalRawGuard = RawGuard, SpannedDecimalValidator>; +pub type DecimalGuard = Guard, DecimalValidator>; + +#[cfg(test)] +mod tests { + use super::*; + use quote::quote; + + fn lit(s: &str) -> DecimalLit { + s.parse::().unwrap() + } + + #[test] + fn scale_insensitive_equality() { + assert_eq!(lit("0.5"), lit("0.50")); + assert_eq!(lit("1"), lit("1.000")); + } + + #[test] + fn ordering() { + assert!(lit("-0.1") < lit("0.1")); + assert!(lit("1") < lit("1.5")); + assert!(lit("-1.5") < lit("-1")); + } + + #[test] + fn to_tokens_round_trips() { + // Including an out-of-i64-range mantissa: proves we need the i128 + // constructor `from_i128_with_scale`, not `Decimal::new(i64, _)`. + for s in ["0", "1.50", "-0.001", "100000000000000000000"] { + let value = lit(s); + let mantissa = value.0.mantissa(); + let scale = value.0.scale(); + let expected = quote!( + ::rust_decimal::Decimal::from_i128_with_scale(#mantissa, #scale) + ); + assert_eq!(value.to_token_stream().to_string(), expected.to_string()); + // And the reconstruction equals the original value. + assert_eq!( + rust_decimal::Decimal::from_i128_with_scale(mantissa, scale), + value.0 + ); + } + } + + #[test] + fn rejects_invalid_literal() { + assert!("1.2.3".parse::().is_err()); + // Out of Decimal's range. + assert!("1e40".parse::().is_err()); + } +} diff --git a/nutype_macros/src/decimal/parse.rs b/nutype_macros/src/decimal/parse.rs new file mode 100644 index 00000000..98e9c246 --- /dev/null +++ b/nutype_macros/src/decimal/parse.rs @@ -0,0 +1,223 @@ +use core::{ + fmt::{Debug, Display}, + str::FromStr, +}; + +use crate::common::{ + models::{Attributes, ConstFn, SpannedDeriveTrait, TypeName}, + parse::{ + ParseableAttributes, parse_number_or_expr, parse_sanitizer_kind, + parse_typed_custom_function_raw, parse_validator_kind, + }, +}; +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::{ + Token, + parse::{Parse, ParseStream}, +}; + +use super::{ + models::{ + DecimalGuard, DecimalLit, DecimalRawGuard, DecimalSanitizer, DecimalSanitizerKind, + DecimalValidator, DecimalValidatorKind, SpannedDecimalSanitizer, SpannedDecimalValidator, + }, + validate::validate_decimal_guard, +}; + +pub fn parse_attributes( + input: TokenStream, + type_name: &TypeName, +) -> Result, SpannedDeriveTrait>, syn::Error> +where + T: FromStr + PartialOrd + Clone, + ::Err: Debug + Display, +{ + let attrs: ParseableAttributes, SpannedDecimalValidator> = + syn::parse2(input)?; + + let ParseableAttributes { + sanitizers, + validation, + new_unchecked, + const_fn, + constructor_visibility, + default, + derive_traits, + derive_unchecked_traits, + cfg_attr_entries, + } = attrs; + + // Decision 4: `const_fn` is not supported for Decimal, because Decimal's + // comparison operators are not `const`. Reject it with a friendly message + // instead of letting it fail deep in codegen. + if let ConstFn::Const = const_fn { + let msg = concat!( + "`const_fn` is not supported for `rust_decimal::Decimal`, ", + "because Decimal's comparison operators are not `const`." + ); + return Err(syn::Error::new(proc_macro2::Span::call_site(), msg)); + } + + // A bare numeric literal `default = 0.5` would be emitted as a float literal, + // which does not coerce to `Decimal`. Rewrite such literals through + // `DecimalLit` so they emit a real `Decimal`. Non-literal expressions + // (a `dec!(...)` macro call, a constant, `Decimal::ZERO`, etc.) are passed + // through unchanged. + let default = rewrite_decimal_default(default)?; + + let raw_guard = DecimalRawGuard { + sanitizers, + validation, + }; + let guard = validate_decimal_guard(raw_guard, type_name)?; + Ok(Attributes { + new_unchecked, + const_fn, + constructor_visibility, + guard, + default, + derive_traits, + derive_unchecked_traits, + cfg_attr_entries, + }) +} + +/// If `default = ...` is a bare numeric literal (possibly negated), rewrite it +/// into a real `Decimal` expression. Otherwise leave it untouched. +fn rewrite_decimal_default(default: Option) -> Result, syn::Error> { + let Some(expr) = default else { + return Ok(None); + }; + + let Some((number_str, span)) = extract_numeric_literal(&expr) else { + // Not a bare numeric literal (e.g. `dec!(0.5)`, `Decimal::ZERO`, a + // constant). It is expected to already be a `Decimal`; pass it through. + return Ok(Some(expr)); + }; + + let decimal_lit = number_str.parse::().map_err(|err| { + syn::Error::new( + span, + format!("Invalid decimal default value `{number_str}`: {err}"), + ) + })?; + + let rewritten: syn::Expr = syn::parse2(decimal_lit.to_token_stream())?; + Ok(Some(rewritten)) +} + +/// Extract a numeric literal (int or float, optionally prefixed with a unary +/// minus) as a string suitable for `DecimalLit::from_str`, along with its span. +fn extract_numeric_literal(expr: &syn::Expr) -> Option<(String, proc_macro2::Span)> { + match expr { + syn::Expr::Lit(syn::ExprLit { lit, .. }) => lit_to_number_string(lit), + syn::Expr::Unary(syn::ExprUnary { + op: syn::UnOp::Neg(_), + expr, + .. + }) => { + if let syn::Expr::Lit(syn::ExprLit { lit, .. }) = expr.as_ref() { + lit_to_number_string(lit).map(|(s, span)| (format!("-{s}"), span)) + } else { + None + } + } + _ => None, + } +} + +fn lit_to_number_string(lit: &syn::Lit) -> Option<(String, proc_macro2::Span)> { + match lit { + syn::Lit::Int(li) => Some((li.to_string().replace('_', ""), li.span())), + syn::Lit::Float(lf) => Some((lf.to_string().replace('_', ""), lf.span())), + _ => None, + } +} + +impl Parse for SpannedDecimalValidator +where + T: FromStr, + ::Err: Display, +{ + fn parse(input: ParseStream) -> syn::Result { + let (kind, _ident) = parse_validator_kind(input)?; + + match kind { + DecimalValidatorKind::Greater => { + let _eq: Token![=] = input.parse()?; + let (number, span) = parse_number_or_expr::(input)?; + Ok(SpannedDecimalValidator { + item: DecimalValidator::Greater(number), + span, + }) + } + DecimalValidatorKind::GreaterOrEqual => { + let _eq: Token![=] = input.parse()?; + let (number, span) = parse_number_or_expr::(input)?; + Ok(SpannedDecimalValidator { + item: DecimalValidator::GreaterOrEqual(number), + span, + }) + } + DecimalValidatorKind::Less => { + let _eq: Token![=] = input.parse()?; + let (number, span) = parse_number_or_expr::(input)?; + Ok(SpannedDecimalValidator { + item: DecimalValidator::Less(number), + span, + }) + } + DecimalValidatorKind::LessOrEqual => { + let _eq: Token![=] = input.parse()?; + let (number, span) = parse_number_or_expr::(input)?; + Ok(SpannedDecimalValidator { + item: DecimalValidator::LessOrEqual(number), + span, + }) + } + DecimalValidatorKind::Predicate => { + let _eq: Token![=] = input.parse()?; + // The predicate closure receives `&Decimal`. We must inject the real + // inner type, not the internal `DecimalLit` literal type (`T`), otherwise + // an untyped closure like `|d| ...` would expand to reference the private + // `nutype_macros::...::DecimalLit` type and fail to compile. + let (typed_custom_function, span) = + parse_typed_custom_function_raw(input, "&::rust_decimal::Decimal")?; + Ok(SpannedDecimalValidator { + item: DecimalValidator::Predicate(typed_custom_function), + span, + }) + } + } + } +} + +impl Parse for SpannedDecimalSanitizer +where + T: FromStr, + ::Err: Display, +{ + fn parse(input: ParseStream) -> syn::Result { + let (kind, ident) = parse_sanitizer_kind(input)?; + + match kind { + DecimalSanitizerKind::With => { + let _eq: Token![=] = input.parse()?; + // The sanitizer closure receives `Decimal` by value. Inject the real + // inner type rather than the internal `DecimalLit` literal type (`T`), + // so that an untyped closure like `|d| ...` compiles. + let (typed_custom_function, span) = + parse_typed_custom_function_raw(input, "::rust_decimal::Decimal")?; + Ok(SpannedDecimalSanitizer { + item: DecimalSanitizer::With(typed_custom_function), + span, + }) + } + DecimalSanitizerKind::_Phantom => { + let msg = format!("Unknown sanitizer `{ident}`"); + Err(syn::Error::new(ident.span(), msg)) + } + } + } +} diff --git a/nutype_macros/src/decimal/validate.rs b/nutype_macros/src/decimal/validate.rs new file mode 100644 index 00000000..1ca15a1d --- /dev/null +++ b/nutype_macros/src/decimal/validate.rs @@ -0,0 +1,127 @@ +use proc_macro2::Span; + +use crate::common::{ + models::{CfgAttrEntry, DeriveTrait, SpannedDeriveTrait, TypeName, ValidatedDerives}, + validate::{ + validate_all_derive_traits, validate_duplicates, validate_guard, validate_numeric_bounds, + }, +}; + +use super::models::{ + DecimalDeriveTrait, DecimalGuard, DecimalRawGuard, DecimalSanitizer, DecimalValidator, + SpannedDecimalSanitizer, SpannedDecimalValidator, +}; + +pub fn validate_decimal_guard( + raw_guard: DecimalRawGuard, + type_name: &TypeName, +) -> Result, syn::Error> +where + T: PartialOrd + Clone, +{ + validate_guard( + raw_guard, + type_name, + validate_validators, + validate_sanitizers, + ) +} + +fn validate_validators( + validators: Vec>, +) -> Result>, syn::Error> +where + T: PartialOrd + Clone, +{ + validate_duplicates(&validators, |kind| { + format!( + "Duplicated validator `{kind}`.\nYou're a great engineer, but don't forget to take care of yourself!" + ) + })?; + + validate_numeric_bounds(&validators)?; + + let validators: Vec<_> = validators.into_iter().map(|v| v.item).collect(); + Ok(validators) +} + +fn validate_sanitizers( + sanitizers: Vec>, +) -> Result>, syn::Error> +where + T: PartialOrd + Clone, +{ + validate_duplicates(&sanitizers, |kind| { + format!("Duplicated sanitizer `{kind}`.\nIt happens, don't worry. We still love you!") + })?; + + let sanitizers: Vec<_> = sanitizers.into_iter().map(|s| s.item).collect(); + Ok(sanitizers) +} + +pub fn validate_decimal_derive_traits( + derive_traits: Vec, + has_validation: bool, + cfg_attr_entries: &[CfgAttrEntry], + maybe_default_value: &Option, + type_name: &TypeName, +) -> Result, syn::Error> { + validate_all_derive_traits( + has_validation, + derive_traits, + cfg_attr_entries, + maybe_default_value, + type_name, + to_decimal_derive_trait, + ) +} + +pub(crate) fn to_decimal_derive_trait( + tr: DeriveTrait, + has_validation: bool, + span: Span, +) -> Result { + match tr { + DeriveTrait::Debug => Ok(DecimalDeriveTrait::Debug), + DeriveTrait::Display => Ok(DecimalDeriveTrait::Display), + DeriveTrait::Default => Ok(DecimalDeriveTrait::Default), + DeriveTrait::Clone => Ok(DecimalDeriveTrait::Clone), + DeriveTrait::PartialEq => Ok(DecimalDeriveTrait::PartialEq), + DeriveTrait::Eq => Ok(DecimalDeriveTrait::Eq), + DeriveTrait::PartialOrd => Ok(DecimalDeriveTrait::PartialOrd), + DeriveTrait::Ord => Ok(DecimalDeriveTrait::Ord), + DeriveTrait::Into => Ok(DecimalDeriveTrait::Into), + DeriveTrait::FromStr => Ok(DecimalDeriveTrait::FromStr), + DeriveTrait::AsRef => Ok(DecimalDeriveTrait::AsRef), + DeriveTrait::Deref => Ok(DecimalDeriveTrait::Deref), + DeriveTrait::Hash => Ok(DecimalDeriveTrait::Hash), + DeriveTrait::Borrow => Ok(DecimalDeriveTrait::Borrow), + DeriveTrait::Copy => Ok(DecimalDeriveTrait::Copy), + DeriveTrait::SerdeSerialize => Ok(DecimalDeriveTrait::SerdeSerialize), + DeriveTrait::SerdeDeserialize => Ok(DecimalDeriveTrait::SerdeDeserialize), + DeriveTrait::ArbitraryArbitrary => Ok(DecimalDeriveTrait::ArbitraryArbitrary), + DeriveTrait::TryFrom => Ok(DecimalDeriveTrait::TryFrom), + DeriveTrait::From => { + if has_validation { + Err(syn::Error::new( + span, + "#[nutype] cannot derive `From` trait, because there is validation defined. Use `TryFrom` instead.", + )) + } else { + Ok(DecimalDeriveTrait::From) + } + } + DeriveTrait::IntoIterator => Err(syn::Error::new( + span, + "#[nutype] cannot derive `IntoIterator` trait for decimal types. Inner type must be a collection type.", + )), + DeriveTrait::SchemarsJsonSchema => Err(syn::Error::new( + span, + "#[nutype] does not support deriving `JsonSchema` for `rust_decimal::Decimal` yet.", + )), + DeriveTrait::ValuableValuable => Err(syn::Error::new( + span, + "#[nutype] cannot derive `Valuable` trait for `rust_decimal::Decimal`, because it does not implement `valuable::Valuable`.", + )), + } +} diff --git a/nutype_macros/src/lib.rs b/nutype_macros/src/lib.rs index 16aebe1c..5b7848bb 100644 --- a/nutype_macros/src/lib.rs +++ b/nutype_macros/src/lib.rs @@ -6,6 +6,8 @@ mod any; mod common; +#[cfg(feature = "rust_decimal")] +mod decimal; mod float; mod integer; mod string; @@ -16,6 +18,8 @@ use common::{ models::{InnerType, Newtype, TypedMeta}, parse::meta::parse_meta, }; +#[cfg(feature = "rust_decimal")] +use decimal::{DecimalNewtype, models::DecimalLit}; use float::{FloatNewtype, models::FloatInnerType}; use integer::{IntegerNewtype, models::IntegerInnerType}; use proc_macro2::TokenStream; @@ -45,6 +49,8 @@ fn expand_nutype( InnerType::String(tp) => StringNewtype::expand(typed_meta, tp), InnerType::Integer(inner) => expand_nutype_integer(typed_meta, inner), InnerType::Float(inner) => expand_nutype_float(typed_meta, inner), + #[cfg(feature = "rust_decimal")] + InnerType::Decimal(inner) => DecimalNewtype::::expand(typed_meta, inner), InnerType::Any(any_inner_type) => AnyNewtype::expand(typed_meta, any_inner_type), } } diff --git a/test_suite/Cargo.toml b/test_suite/Cargo.toml index 88e27522..4e6c2ff4 100644 --- a/test_suite/Cargo.toml +++ b/test_suite/Cargo.toml @@ -23,6 +23,9 @@ rmp-serde = "1.1.2" num = "0.4.3" thiserror = "1.0.63" valuable = { version = "0.1.1", features = ["derive"], optional = true } +# `rust-fuzz` provides `impl Arbitrary for Decimal`; `serde` provides its serde impls. +rust_decimal = { version = "1", features = ["rust-fuzz", "serde"], optional = true } +rust_decimal_macros = { version = "1", optional = true } [features] serde = ["nutype/serde", "dep:serde", "dep:serde_json"] @@ -32,3 +35,4 @@ schemars08 = ["schemars"] new_unchecked = [] ui = [] valuable = ["nutype/valuable", "dep:valuable"] +rust_decimal = ["nutype/rust_decimal", "dep:rust_decimal", "dep:rust_decimal_macros"] diff --git a/test_suite/tests/compiletest.rs b/test_suite/tests/compiletest.rs index f10ffe6b..324ad92d 100644 --- a/test_suite/tests/compiletest.rs +++ b/test_suite/tests/compiletest.rs @@ -4,3 +4,33 @@ fn ui() { let t = trybuild::TestCases::new(); t.compile_fail("tests/ui/**/*.rs"); } + +// Decimal UI fixtures are split by whether the `rust_decimal` feature is enabled, +// because the expected error differs: +// * with the feature ON, fixtures exercise Decimal-specific validation errors; +// * with the feature OFF, the fixture exercises the "enable the feature" error. +// They live outside `tests/ui/**` so the main glob above never picks them up, +// which keeps `--all-features` (ui + rust_decimal) consistent. +#[cfg(all(feature = "ui", feature = "rust_decimal"))] +#[test] +fn ui_decimal_on() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui_decimal_on/**/*.rs"); +} + +#[cfg(all(feature = "ui", not(feature = "rust_decimal")))] +#[test] +fn ui_decimal_off() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui_decimal_off/**/*.rs"); +} + +// Fixtures that additionally need the `arbitrary` feature to reach the +// Decimal-specific rejection (otherwise `derive(Arbitrary)` is rejected earlier +// by the feature gate, which would make the snapshot feature-sensitive). +#[cfg(all(feature = "ui", feature = "rust_decimal", feature = "arbitrary"))] +#[test] +fn ui_decimal_on_arbitrary() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui_decimal_on_arbitrary/**/*.rs"); +} diff --git a/test_suite/tests/decimal.rs b/test_suite/tests/decimal.rs new file mode 100644 index 00000000..cb879c3d --- /dev/null +++ b/test_suite/tests/decimal.rs @@ -0,0 +1,395 @@ +#![cfg(feature = "rust_decimal")] +//! Integration tests for `rust_decimal::Decimal` as an inner type. +//! Run with: `cargo test -p test_suite --features rust_decimal` + +use core::str::FromStr; +use nutype::nutype; +use rust_decimal::Decimal; + +/// Small helper to build a `Decimal` from a string literal in tests. +fn d(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +mod detection { + use super::*; + + // All three spellings of the inner type must be detected. + #[nutype(derive(Debug))] + pub struct A(Decimal); + + #[nutype(derive(Debug))] + pub struct B(rust_decimal::Decimal); + + #[nutype(derive(Debug))] + pub struct C(::rust_decimal::Decimal); + + #[test] + fn all_forms_compile() { + let _ = A::new(d("1")); + let _ = B::new(d("2")); + let _ = C::new(d("3")); + } +} + +mod bounds { + use super::*; + + #[nutype( + validate(greater_or_equal = 0, less_or_equal = 100), + derive(Debug, Clone, Copy, PartialEq, PartialOrd, Display) + )] + pub struct Percentage(Decimal); + + #[test] + fn accepts_in_range() { + assert_eq!( + Percentage::try_new(d("42.5")).unwrap().into_inner(), + d("42.5") + ); + assert_eq!(Percentage::try_new(d("0")).unwrap().into_inner(), d("0")); + assert_eq!( + Percentage::try_new(d("100")).unwrap().into_inner(), + d("100") + ); + } + + #[test] + fn rejects_out_of_range() { + assert_eq!( + Percentage::try_new(d("150")), + Err(PercentageError::LessOrEqualViolated) + ); + assert_eq!( + Percentage::try_new(d("-0.01")), + Err(PercentageError::GreaterOrEqualViolated) + ); + } + + #[nutype( + validate(greater = 0, less = 10), + derive(Debug, Clone, Copy, PartialEq) + )] + pub struct Exclusive(Decimal); + + #[test] + fn exclusive_bounds() { + assert_eq!( + Exclusive::try_new(d("0")), + Err(ExclusiveError::GreaterViolated) + ); + assert_eq!( + Exclusive::try_new(d("10")), + Err(ExclusiveError::LessViolated) + ); + assert!(Exclusive::try_new(d("5")).is_ok()); + } + + // Scale-insensitive equality of bounds: `0.50` and `0.5` are the same. + #[nutype( + validate(greater_or_equal = 0.50), + derive(Debug, Clone, Copy, PartialEq) + )] + pub struct Scaled(Decimal); + + #[test] + fn scale_insensitive_bound() { + assert!(Scaled::try_new(d("0.5")).is_ok()); + assert_eq!( + Scaled::try_new(d("0.49")), + Err(ScaledError::GreaterOrEqualViolated) + ); + } + + // A bound that does not fit in i64, proving the i128 `from_i128_with_scale` + // emission is genuinely needed. + #[nutype( + validate(greater = 100000000000000000000), + derive(Debug, Clone, Copy, PartialEq) + )] + pub struct Big(Decimal); + + #[test] + fn out_of_i64_range_bound() { + assert_eq!(Big::try_new(d("1")), Err(BigError::GreaterViolated)); + assert!(Big::try_new(d("100000000000000000001")).is_ok()); + } +} + +mod sanitizers { + use super::*; + + #[nutype( + sanitize(with = |d: Decimal| d.round_dp(2)), + validate(greater_or_equal = 0), + derive(Debug, Clone, Copy, PartialEq), + )] + pub struct Money(Decimal); + + #[test] + fn sanitizer_runs_before_validation() { + assert_eq!(Money::try_new(d("9.999")).unwrap().into_inner(), d("10.00")); + } + + // An *untyped* sanitizer closure must compile: the macro has to inject the + // real `Decimal` inner type, not the internal `DecimalLit` literal type. + #[nutype( + sanitize(with = |d| d.round_dp(2)), + derive(Debug, Clone, Copy, PartialEq), + )] + pub struct UntypedMoney(Decimal); + + #[test] + fn untyped_sanitizer_closure() { + assert_eq!(UntypedMoney::new(d("9.999")).into_inner(), d("10.00")); + } +} + +mod predicate { + use super::*; + + #[nutype( + validate(predicate = |d: &Decimal| d.scale() <= 2), + derive(Debug, Clone, Copy, PartialEq), + )] + pub struct Price(Decimal); + + #[test] + fn predicate_validation() { + assert!(Price::try_new(d("9.99")).is_ok()); + assert_eq!( + Price::try_new(d("9.999")), + Err(PriceError::PredicateViolated) + ); + } + + // An *untyped* predicate closure must compile: the macro has to inject the + // real `&Decimal` inner type, not the internal `DecimalLit` literal type. + #[nutype( + validate(predicate = |d| d.scale() <= 2), + derive(Debug, Clone, Copy, PartialEq), + )] + pub struct UntypedPrice(Decimal); + + #[test] + fn untyped_predicate_closure() { + assert!(UntypedPrice::try_new(d("9.99")).is_ok()); + assert_eq!( + UntypedPrice::try_new(d("9.999")), + Err(UntypedPriceError::PredicateViolated) + ); + } +} + +mod derives { + use super::*; + use std::collections::HashSet; + + #[nutype(derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Display, FromStr, AsRef, Deref, + Into, From, Borrow + ))] + pub struct Plain(Decimal); + + #[test] + fn from_and_into() { + let p = Plain::from(d("1.5")); + let inner: Decimal = p.into(); + assert_eq!(inner, d("1.5")); + } + + #[test] + fn as_ref_and_deref() { + let p = Plain::new(d("2.5")); + let r: &Decimal = p.as_ref(); + assert_eq!(*r, d("2.5")); + assert_eq!(*p, d("2.5")); + } + + #[test] + fn ord_eq_hash() { + // Eq/Ord/Hash all work for Decimal with no `finite`-style caveat. + assert!(Plain::new(d("1")) < Plain::new(d("2"))); + let mut set = HashSet::new(); + set.insert(Plain::new(d("1.0"))); + // 1.0 == 1.00 for Decimal, and Hash is consistent with Eq. + assert!(set.contains(&Plain::new(d("1.00")))); + } + + #[test] + fn display_and_from_str() { + let p = Plain::new(d("3.14")); + assert_eq!(p.to_string(), "3.14"); + let parsed = Plain::from_str("3.14").unwrap(); + assert_eq!(parsed, p); + } + + #[nutype( + validate(greater_or_equal = 0), + derive(Debug, Clone, Copy, PartialEq, TryFrom) + )] + pub struct NonNegative(Decimal); + + #[test] + fn try_from_with_validation() { + assert!(NonNegative::try_from(d("1")).is_ok()); + assert!(NonNegative::try_from(d("-1")).is_err()); + } +} + +mod default_trait { + use super::*; + use rust_decimal_macros::dec; + + // Constant / path expression form. + #[nutype( + derive(Debug, Clone, Copy, PartialEq, Default), + default = Decimal::ZERO, + )] + pub struct Quantity(Decimal); + + // Bare integer literal form. + #[nutype(derive(Debug, Clone, Copy, PartialEq, Default), default = 0)] + pub struct Count(Decimal); + + // Bare float literal form. + #[nutype(derive(Debug, Clone, Copy, PartialEq, Default), default = 0.5)] + pub struct Ratio(Decimal); + + // Negative float literal form, combined with validation. + #[nutype( + validate(greater_or_equal = -10), + derive(Debug, Clone, Copy, PartialEq, Default), + default = -1.5, + )] + pub struct Offset(Decimal); + + // Negative integer literal form. + #[nutype(derive(Debug, Clone, Copy, PartialEq, Default), default = -7)] + pub struct Temperature(Decimal); + + // `dec!(...)` macro form. + #[nutype(derive(Debug, Clone, Copy, PartialEq, Default), default = dec!(0.25))] + pub struct Fraction(Decimal); + + // Out-of-i64-range literal default, to confirm the i128 path is used. + #[nutype( + derive(Debug, Clone, Copy, PartialEq, Default), + default = 100000000000000000000 + )] + pub struct Huge(Decimal); + + #[test] + fn default_value() { + assert_eq!(Quantity::default().into_inner(), Decimal::ZERO); + assert_eq!(Count::default().into_inner(), d("0")); + assert_eq!(Ratio::default().into_inner(), d("0.5")); + assert_eq!(Offset::default().into_inner(), d("-1.5")); + assert_eq!(Temperature::default().into_inner(), d("-7")); + assert_eq!(Fraction::default().into_inner(), dec!(0.25)); + assert_eq!(Huge::default().into_inner(), d("100000000000000000000")); + } +} + +#[cfg(feature = "serde")] +mod serde_support { + use super::*; + + #[nutype( + validate(greater_or_equal = 0, less_or_equal = 100), + derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize) + )] + pub struct Percent(Decimal); + + #[test] + fn round_trip() { + let p = Percent::try_new(d("12.5")).unwrap(); + let json = serde_json::to_string(&p).unwrap(); + let back: Percent = serde_json::from_str(&json).unwrap(); + assert_eq!(p, back); + } + + #[test] + fn deserialize_rejects_out_of_range() { + let result: Result = serde_json::from_str("150"); + assert!(result.is_err()); + } +} + +#[cfg(feature = "arbitrary")] +mod arbitrary_support { + use super::*; + use arbitrary::{Arbitrary, Unstructured}; + + #[nutype(derive(Debug, Clone, Copy, PartialEq, Arbitrary))] + pub struct AnyDecimal(Decimal); + + #[nutype( + validate(greater_or_equal = 0, less_or_equal = 100), + derive(Debug, Clone, Copy, PartialEq, PartialOrd, Arbitrary) + )] + pub struct BoundedPercentage(Decimal); + + #[nutype( + validate(greater_or_equal = 0), + derive(Debug, Clone, Copy, PartialEq, PartialOrd, Arbitrary) + )] + pub struct LowerBounded(Decimal); + + #[test] + fn unvalidated_arbitrary() { + let data = [0xABu8; 64]; + let mut u = Unstructured::new(&data); + let _ = AnyDecimal::arbitrary(&mut u).unwrap(); + } + + #[test] + fn bounded_arbitrary_stays_in_range() { + // Many seeds must always produce valid values inside [0, 100]. + for seed in 0u8..=255 { + let data = [seed; 64]; + let mut u = Unstructured::new(&data); + if let Ok(value) = BoundedPercentage::arbitrary(&mut u) { + let inner = value.into_inner(); + assert!( + inner >= d("0") && inner <= d("100"), + "out of range: {inner}" + ); + } + } + } + + #[test] + fn lower_bounded_arbitrary_stays_in_range() { + for seed in 0u8..=255 { + let data = [seed; 64]; + let mut u = Unstructured::new(&data); + if let Ok(value) = LowerBounded::arbitrary(&mut u) { + assert!(value.into_inner() >= d("0")); + } + } + } +} + +mod constructor_visibility { + use super::*; + + #[nutype( + validate(greater_or_equal = 0), + constructor(visibility = private), + derive(Debug, Clone, Copy, PartialEq), + )] + pub struct Internal(Decimal); + + impl Internal { + pub fn make(value: Decimal) -> Option { + Self::try_new(value).ok() + } + } + + #[test] + fn private_constructor() { + assert!(Internal::make(d("1")).is_some()); + assert!(Internal::make(d("-1")).is_none()); + } +} diff --git a/test_suite/tests/ui_decimal_off/feature_off.rs b/test_suite/tests/ui_decimal_off/feature_off.rs new file mode 100644 index 00000000..56286d72 --- /dev/null +++ b/test_suite/tests/ui_decimal_off/feature_off.rs @@ -0,0 +1,8 @@ +use nutype::nutype; + +// Without the `rust_decimal` feature enabled, wrapping `Decimal` must produce a +// friendly error pointing the user at the feature flag. +#[nutype(derive(Debug))] +struct Price(rust_decimal::Decimal); + +fn main() {} diff --git a/test_suite/tests/ui_decimal_off/feature_off.stderr b/test_suite/tests/ui_decimal_off/feature_off.stderr new file mode 100644 index 00000000..44e630da --- /dev/null +++ b/test_suite/tests/ui_decimal_off/feature_off.stderr @@ -0,0 +1,9 @@ +error: To use `Decimal` as the inner type, enable the `rust_decimal` feature of crate `nutype`, e.g. + + nutype = { version = "0.7", features = ["rust_decimal"] } + + You also need to add `rust_decimal` as a dependency of your crate. + --> tests/ui_decimal_off/feature_off.rs:6:14 + | +6 | struct Price(rust_decimal::Decimal); + | ^^^^^^^^^^^^ diff --git a/test_suite/tests/ui_decimal_on/const_fn_rejected.rs b/test_suite/tests/ui_decimal_on/const_fn_rejected.rs new file mode 100644 index 00000000..2aa139d5 --- /dev/null +++ b/test_suite/tests/ui_decimal_on/const_fn_rejected.rs @@ -0,0 +1,13 @@ +use nutype::nutype; +use rust_decimal::Decimal; + +// `const_fn` is not supported for Decimal, because its comparison operators are +// not `const`. +#[nutype( + const_fn, + validate(greater_or_equal = 0), + derive(Debug), +)] +struct Amount(Decimal); + +fn main() {} diff --git a/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr b/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr new file mode 100644 index 00000000..c9fc0b4e --- /dev/null +++ b/test_suite/tests/ui_decimal_on/const_fn_rejected.stderr @@ -0,0 +1,19 @@ +error: `const_fn` is not supported for `rust_decimal::Decimal`, because Decimal's comparison operators are not `const`. + --> tests/ui_decimal_on/const_fn_rejected.rs:6:1 + | + 6 | / #[nutype( + 7 | | const_fn, + 8 | | validate(greater_or_equal = 0), + 9 | | derive(Debug), +10 | | )] + | |__^ + | + = note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused import: `rust_decimal::Decimal` + --> tests/ui_decimal_on/const_fn_rejected.rs:2:5 + | +2 | use rust_decimal::Decimal; + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/test_suite/tests/ui_decimal_on/derive_valuable.rs b/test_suite/tests/ui_decimal_on/derive_valuable.rs new file mode 100644 index 00000000..aee555a5 --- /dev/null +++ b/test_suite/tests/ui_decimal_on/derive_valuable.rs @@ -0,0 +1,9 @@ +use nutype::nutype; +use rust_decimal::Decimal; + +// `Valuable` cannot be derived for Decimal, because `rust_decimal::Decimal` does +// not implement `valuable::Valuable`. +#[nutype(derive(Debug, Valuable))] +struct Amount(Decimal); + +fn main() {} diff --git a/test_suite/tests/ui_decimal_on/derive_valuable.stderr b/test_suite/tests/ui_decimal_on/derive_valuable.stderr new file mode 100644 index 00000000..3a92f060 --- /dev/null +++ b/test_suite/tests/ui_decimal_on/derive_valuable.stderr @@ -0,0 +1,13 @@ +error: #[nutype] cannot derive `Valuable` trait for `rust_decimal::Decimal`, because it does not implement `valuable::Valuable`. + --> tests/ui_decimal_on/derive_valuable.rs:6:24 + | +6 | #[nutype(derive(Debug, Valuable))] + | ^^^^^^^^ + +warning: unused import: `rust_decimal::Decimal` + --> tests/ui_decimal_on/derive_valuable.rs:2:5 + | +2 | use rust_decimal::Decimal; + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/test_suite/tests/ui_decimal_on/invalid_default_literal.rs b/test_suite/tests/ui_decimal_on/invalid_default_literal.rs new file mode 100644 index 00000000..3dbf20fd --- /dev/null +++ b/test_suite/tests/ui_decimal_on/invalid_default_literal.rs @@ -0,0 +1,12 @@ +use nutype::nutype; +use rust_decimal::Decimal; + +// A bare numeric literal default that is out of Decimal's range must produce a +// friendly error rather than emitting broken code. +#[nutype( + derive(Debug, Default), + default = 1e40, +)] +struct Amount(Decimal); + +fn main() {} diff --git a/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr b/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr new file mode 100644 index 00000000..e61d5771 --- /dev/null +++ b/test_suite/tests/ui_decimal_on/invalid_default_literal.stderr @@ -0,0 +1,13 @@ +error: Invalid decimal default value `1e40`: Scale exceeds the maximum precision allowed: 40 > 28 + --> tests/ui_decimal_on/invalid_default_literal.rs:8:15 + | +8 | default = 1e40, + | ^^^^ + +warning: unused import: `rust_decimal::Decimal` + --> tests/ui_decimal_on/invalid_default_literal.rs:2:5 + | +2 | use rust_decimal::Decimal; + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/test_suite/tests/ui_decimal_on/unknown_validator.rs b/test_suite/tests/ui_decimal_on/unknown_validator.rs new file mode 100644 index 00000000..0fd0bd83 --- /dev/null +++ b/test_suite/tests/ui_decimal_on/unknown_validator.rs @@ -0,0 +1,12 @@ +use nutype::nutype; +use rust_decimal::Decimal; + +// `finite` is not a valid validator for Decimal (it cannot be NaN/inf), so it +// must be reported as an unknown validation attribute. +#[nutype( + validate(finite), + derive(Debug), +)] +struct Amount(Decimal); + +fn main() {} diff --git a/test_suite/tests/ui_decimal_on/unknown_validator.stderr b/test_suite/tests/ui_decimal_on/unknown_validator.stderr new file mode 100644 index 00000000..88d65ba7 --- /dev/null +++ b/test_suite/tests/ui_decimal_on/unknown_validator.stderr @@ -0,0 +1,14 @@ +error: Unknown validation attribute: `finite`. + Possible attributes are `greater`, `greater_or_equal`, `less`, `less_or_equal`, `predicate`, `with`, `error`. + --> tests/ui_decimal_on/unknown_validator.rs:7:14 + | +7 | validate(finite), + | ^^^^^^ + +warning: unused import: `rust_decimal::Decimal` + --> tests/ui_decimal_on/unknown_validator.rs:2:5 + | +2 | use rust_decimal::Decimal; + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.rs b/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.rs new file mode 100644 index 00000000..1e363f04 --- /dev/null +++ b/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.rs @@ -0,0 +1,12 @@ +use nutype::nutype; +use rust_decimal::Decimal; + +// Deriving `Arbitrary` with an exclusive bound (`greater` / `less`) is not yet +// supported for Decimal; only inclusive bounds are. +#[nutype( + validate(greater = 0), + derive(Debug, Arbitrary), +)] +struct Amount(Decimal); + +fn main() {} diff --git a/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr b/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr new file mode 100644 index 00000000..141611e4 --- /dev/null +++ b/test_suite/tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.stderr @@ -0,0 +1,19 @@ +error: Deriving `Arbitrary` for a Decimal type currently supports inclusive bounds only (`greater_or_equal` / `less_or_equal`). + Exclusive bounds (`greater` / `less`) are not yet supported, because nudging an exact Decimal strictly past a bound is unsolved here. + --> tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.rs:6:1 + | +6 | / #[nutype( +7 | | validate(greater = 0), +8 | | derive(Debug, Arbitrary), +9 | | )] + | |__^ + | + = note: this error originates in the attribute macro `nutype` (in Nightly builds, run with -Z macro-backtrace for more info) + +warning: unused import: `rust_decimal::Decimal` + --> tests/ui_decimal_on_arbitrary/arbitrary_exclusive_bound.rs:2:5 + | +2 | use rust_decimal::Decimal; + | ^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default