diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fb2b0c..62d0753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: ci: name: CI if: ${{ always() }} - needs: [fmt, clippy, docs, msrv, test, test-sql] + needs: [fmt, clippy, docs, msrv, test] runs-on: ubuntu-latest steps: - name: Result @@ -63,7 +63,7 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: clippy (no default features) run: cargo clippy --no-default-features -- -Dclippy::all - - name: clippy (all features except sql) + - name: clippy (full + serde) run: cargo clippy --features full,serde -- -Dclippy::all - name: clippy (all features) run: cargo clippy --all-features -- -Dclippy::all @@ -129,38 +129,6 @@ jobs: - name: cargo test --doc run: cargo test --doc --features full,serde - # ── Tests (sql feature, requires Postgres) ──────────────────────────────── - test-sql: - name: test-sql (ubuntu-latest / stable) - needs: [fmt, clippy] - runs-on: ubuntu-latest - services: - postgres: - image: postgres:16 - env: - POSTGRES_USER: arvo - POSTGRES_PASSWORD: arvo - POSTGRES_DB: arvo_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - DATABASE_URL: postgres://arvo:arvo@localhost:5432/arvo_test - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: sql - - name: cargo test (sql) - run: cargo test --features sql - - name: cargo test (all features) - run: cargo test --all-features - # ── Minimal dependency versions ─────────────────────────────────────────── minimal-versions: name: minimal-versions diff --git a/Cargo.lock b/Cargo.lock index 9e24c40..44e8cd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,12 +22,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -55,27 +49,16 @@ version = "0.9.0" dependencies = [ "base64", "chrono", - "once_cell", "regex", "rust_decimal", "serde", "serde_json", - "sqlx", "thiserror", "ulid", "url", "uuid", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -88,20 +71,11 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -dependencies = [ - "serde_core", -] [[package]] name = "bitvec" @@ -115,15 +89,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "borsh" version = "1.6.1" @@ -176,12 +141,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.11.1" @@ -224,159 +183,24 @@ dependencies = [ "windows-link", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", - "subtle", -] - -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "etcetera" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" -dependencies = [ - "cfg-if", - "home", - "windows-sys 0.48.0", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "foldhash" version = "0.1.5" @@ -398,87 +222,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[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-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -530,8 +273,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -541,54 +282,12 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -657,15 +356,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -678,101 +368,18 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.4", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "pkg-config", - "vcpkg", -] - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -780,7 +387,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -789,89 +395,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1015,24 +544,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.12.3" @@ -1100,26 +611,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rust_decimal" version = "1.41.0" @@ -1143,18 +634,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "seahash" version = "4.1.0" @@ -1210,298 +689,18 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest", - "rand_core 0.6.4", -] - [[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 = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "serde_json", - "sha2", - "smallvec", - "thiserror", - "tracing", - "url", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.117", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-mysql", - "sqlx-postgres", - "sqlx-sqlite", - "syn 2.0.117", - "url", -] - -[[package]] -name = "sqlx-mysql" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "bytes", - "crc", - "digest", - "dotenvy", - "either", - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "percent-encoding", - "rand 0.8.6", - "rsa", - "serde", - "sha1", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-postgres" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" -dependencies = [ - "atoi", - "base64", - "bitflags", - "byteorder", - "crc", - "dotenvy", - "etcetera", - "futures-channel", - "futures-core", - "futures-util", - "hex", - "hkdf", - "hmac", - "home", - "itoa", - "log", - "md-5", - "memchr", - "once_cell", - "rand 0.8.6", - "serde", - "serde_json", - "sha2", - "smallvec", - "sqlx-core", - "stringprep", - "thiserror", - "tracing", - "whoami", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror", - "tracing", - "url", -] - -[[package]] -name = "stringprep" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" -dependencies = [ - "unicode-bidi", - "unicode-normalization", - "unicode-properties", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -1595,44 +794,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "ulid" version = "1.2.1" @@ -1664,12 +825,6 @@ dependencies = [ "tinyvec", ] -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -1698,12 +853,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -1718,11 +867,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", ] [[package]] @@ -1731,15 +880,9 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] -[[package]] -name = "wasite" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" - [[package]] name = "wasm-bindgen" version = "0.2.118" @@ -1830,16 +973,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "whoami" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" -dependencies = [ - "libredox", - "wasite", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -1900,88 +1033,19 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" +name = "winnow" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "memchr", ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[package]] -name = "winnow" -version = "1.0.1" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "wit-bindgen" @@ -2100,12 +1164,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index cce2acb..9a46e4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ rust-version = "1.85" default = [] # Domain modules — opt-in so you only pay for what you use -contact = ["dep:once_cell", "dep:regex", "dep:url"] +contact = ["dep:regex", "dep:url"] finance = ["dep:rust_decimal", "dep:chrono"] geo = [] measurement = [] @@ -29,7 +29,6 @@ temporal = ["dep:chrono"] # Cross-cutting concerns can be combined with any module serde = ["dep:serde"] -sql = ["dep:sqlx"] # Everything at once full = [ @@ -45,7 +44,6 @@ full = [ [dependencies] thiserror = "2" -once_cell = { version = "1", optional = true } regex = { version = "1", optional = true } rust_decimal = { version = "1.26", optional = true } chrono = { version = "0.4.23", optional = true, features = ["serde"] } @@ -53,8 +51,7 @@ uuid = { version = "1", optional = true, features = ["v4"] } ulid = { version = "1", optional = true } url = { version = "~2.4", optional = true } base64 = { version = "0.22", optional = true } -serde = { version = "1", optional = true, features = ["derive"] } -sqlx = { version = "0.8", optional = true, features = ["postgres"] } +serde = { version = "1.0.116", optional = true, features = ["derive"] } [dev-dependencies] serde_json = "1" diff --git a/README.md b/README.md index 7fdceae..2ff1051 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,11 @@ let email: EmailAddress = "user@example.com".try_into()?; - [Installation](#installation) - [Feature flags](#feature-flags) - [Quick start](#quick-start) -- [The `ValueObject` trait](#the-valueobject-trait) +- [The trait hierarchy](#the-trait-hierarchy) - [Error handling](#error-handling) +- [Parsing from strings](#parsing-from-strings) - [Serde support](#serde-support) +- [Database / ORM integration](#database--orm-integration) - [Roadmap](#roadmap) - [Contributing](#contributing) @@ -43,7 +45,7 @@ let email: EmailAddress = "user@example.com".try_into()?; | Document | Description | |---|---| | [docs/value-objects.md](docs/value-objects.md) | What value objects are, simple vs composite, normalisation | -| [docs/implementing.md](docs/implementing.md) | How to implement the `ValueObject` trait for custom types | +| [docs/implementing.md](docs/implementing.md) | How to implement the traits for custom types | | [docs/contact.md](docs/contact.md) | Reference for all `contact` module types | | [docs/finance.md](docs/finance.md) | Reference for all `finance` module types | | [docs/geo.md](docs/geo.md) | Reference for all `geo` module types | @@ -59,7 +61,7 @@ let email: EmailAddress = "user@example.com".try_into()?; ```toml [dependencies] -arvo = { version = "0.9", features = ["contact", "serde"] } +arvo = { version = "1.0", features = ["contact", "serde"] } ``` Enable only the modules you need — unused features add zero dependencies. @@ -78,7 +80,7 @@ Enable only the modules you need — unused features add zero dependencies. | `identifiers` | `Slug`, `Ean13`, `Ean8`, `Isbn13`, `Isbn10`, `Issn`, `Vin` | — | | `primitives` | `NonEmptyString`, `BoundedString`, `PositiveInt`, `NonNegativeInt`, `PositiveDecimal`, `NonNegativeDecimal`, `Probability`, `HexColor`, `Locale`, `Base64String` | `rust_decimal`, `base64` | | `temporal` | `UnixTimestamp`, `BirthDate`, `ExpiryDate`, `TimeRange`, `BusinessHours` | `chrono` | -| `serde` | `Serialize` / `Deserialize` on all types | `serde` | +| `serde` | `Serialize` / `Deserialize` on all types — deserialisation validates | `serde` | | `full` | All domain modules | all of the above | > **Tip:** `serde` and `full` are orthogonal — combine them freely: @@ -92,7 +94,7 @@ Enable only the modules you need — unused features add zero dependencies. use arvo::contact::{CountryCode, PhoneNumber, PhoneNumberInput}; use arvo::prelude::*; -// Simple value object — validated and normalised on construction +// Construct via new() — validates and normalises on construction let email = EmailAddress::new("User@Example.COM".into())?; assert_eq!(email.value(), "user@example.com"); // always lowercase assert_eq!(email.domain(), "example.com"); @@ -104,7 +106,7 @@ let email: EmailAddress = "hello@example.com".try_into()?; let country = CountryCode::new("cz".into())?; assert_eq!(country.value(), "CZ"); -// Composite value object — structured input, canonical E.164 output +// Composite value object — structured input, multiple accessors let phone = PhoneNumber::new(PhoneNumberInput { country_code: CountryCode::new("CZ".into())?, number: "123 456 789".into(), // formatting stripped automatically @@ -119,48 +121,48 @@ println!("{err}"); // 'not-an-email' is not a valid EmailAddress --- -## The `ValueObject` trait +## The trait hierarchy -Every type in arvo implements the same core interface: +arvo uses two traits: ```rust,ignore +// Base trait — all value objects pub trait ValueObject: Sized + Clone + PartialEq { - /// What `new()` accepts — raw primitive for simple types, - /// a dedicated input struct for composites. type Input; - - /// What `value()` returns — same as `Input` for simple types, - /// canonical representation (e.g. E.164 string) for composites. - type Output: ?Sized; - type Error: std::error::Error; - /// Only way to construct — validates and normalises the input. fn new(value: Self::Input) -> Result; - - /// Returns the validated output value. - fn value(&self) -> &Self::Output; - - /// Consumes and returns the original input. fn into_inner(self) -> Self::Input; } + +// Subtrait — simple single-primitive newtypes only +pub trait PrimitiveValue: ValueObject { + type Primitive: ?Sized; + fn value(&self) -> &Self::Primitive; +} ``` -**Simple type** — `Input` and `Output` are the same (`String`): +**Simple types** implement both — `value()` returns the inner primitive: ```rust,ignore let email = EmailAddress::new("user@example.com".into())?; email.value() // &String → "user@example.com" email.into_inner() // String → "user@example.com" ``` -**Composite type** — `Input` is a struct, `Output` is canonical string: +**Composite types** implement only `ValueObject` — data is accessed through dedicated methods: ```rust,ignore let phone = PhoneNumber::new(PhoneNumberInput { country_code, number })?; -phone.value() // &String → "+420123456789" (E.164) -phone.into_inner() // PhoneNumberInput { country_code, number } +phone.value() // &str → "+420123456789" (inherent method, not trait) +phone.calling_code() // &str → "+420" +phone.into_inner() // PhoneNumberInput { country_code, number } ``` -You can implement it for your own domain types using the provided implementations as a reference. +Use `PrimitiveValue` as a generic bound when you need access to the inner value: +```rust,ignore +fn print_value>(v: &T) { + println!("{}", v.value()); +} +``` --- @@ -185,20 +187,88 @@ match EmailAddress::new("bad".into()) { --- +## Parsing from strings + +Every type implements `TryFrom<&str>` (and therefore `.try_into()`) that parses the canonical string representation and validates in one step: + +```rust,ignore +// Simple types parse their primitive value +let lat: Latitude = "48.8588".try_into()?; +let port: Port = "8080".try_into()?; +let ts: UnixTimestamp = "1700000000".try_into()?; +let dob: BirthDate = "1990-06-15".try_into()?; + +// Composite types parse their canonical string format +let money: Money = "10.50 EUR".try_into()?; +let rate: ExchangeRate = "EUR/USD 1.0850".try_into()?; +let coord: Coordinate = "48.858844, 2.294351".try_into()?; +let len: Length = "1.5 km".try_into()?; +let range: TimeRange = "2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC".try_into()?; +let hours: BusinessHours = "Mon 09:00–17:00".try_into()?; +``` + +Parsing errors return `ValidationError` just like `::new()`. + +> **Note:** `PhoneNumber` and `PostalAddress` do not implement `TryFrom<&str>` — their +> canonical strings are not unambiguously reversible back to a structured input. + +--- + ## Serde support -Enable the `serde` feature. All types serialize as their raw primitive (transparent newtype): +Enable the `serde` feature. All types serialize as their raw primitive and **deserialisation validates** — invalid values are rejected at parse time: ```rust,ignore use arvo::contact::EmailAddress; let email = EmailAddress::new("user@example.com".into())?; - let json = serde_json::to_string(&email)?; // → "\"user@example.com\"" -// Deserialization validates — invalid JSON values are rejected at parse time +// Deserialisation goes through new() — domain validation is enforced let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; + +// Invalid values are rejected at parse time +let err: Result = serde_json::from_str(r#""not-an-email""#); +assert!(err.is_err()); +``` + +Composite types (`PostalAddress`) serialise as their structured `Input` type (JSON object). + +--- + +## Database / ORM integration + +arvo intentionally has no database dependency. Integrate using the accessors arvo provides — this works with any ORM and enables multi-column storage for composite types: + +**Raw sqlx — simple types:** +```rust,ignore +// Bind — extract the primitive +query.bind(email.value()) +query.bind(country.value()) + +// Read back — construct via new() +let s: String = row.get("email"); +let email = EmailAddress::new(s)?; +``` + +**SeaORM / Diesel — composite types as multiple columns:** +```rust,ignore +// Define your own entity with individual columns +#[derive(DeriveEntityModel)] +pub struct Model { + pub street: String, pub city: String, + pub zip: String, pub country: String, +} + +// Convert via into_inner() +impl From for Model { + fn from(addr: PostalAddress) -> Self { + let i = addr.into_inner(); + Model { street: i.street, city: i.city, + zip: i.zip, country: i.country.into_inner() } + } +} ``` --- @@ -222,6 +292,17 @@ let parsed: EmailAddress = serde_json::from_str(r#""hello@example.com""#)?; --- +## Migration from 0.x to 1.0 + +| What changed | Migration | +|---|---| +| `ValueObject::value()` moved to `PrimitiveValue` | Change `T: ValueObject` to `T: PrimitiveValue` if you call `.value()` generically | +| `type Output` removed from `ValueObject` | Replace `::Output` with the concrete type | +| `XxxOutput` type aliases removed | Replace `EmailAddressOutput` with `String`, `PortOutput` with `u16`, etc. | +| `sql` feature removed | Use `.value()` / `.into_inner()` to bind primitives; implement sqlx traits yourself if needed | + +--- + ## Contributing Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. diff --git a/docs/contact.md b/docs/contact.md index 1700c1a..6f6c9c6 100644 --- a/docs/contact.md +++ b/docs/contact.md @@ -131,6 +131,9 @@ pub struct PhoneNumberInput { | `number: "123"` | `ValidationError::InvalidFormat` (too short, min 4 digits) | | `number: "123456789012345"` | `ValidationError::InvalidFormat` (too long, max 14 digits) | +> **Serde:** serialises as a JSON object `{ "country_code": "CZ", "number": "123456789" }` (the input struct). +> **TryFrom\<&str\>:** not implemented — the E.164 canonical string cannot be unambiguously decoded back to structured `PhoneNumberInput`. + --- ## Website @@ -227,3 +230,6 @@ pub struct PostalAddressInput { | `street` | `""` or whitespace | `ValidationError::Empty` | | `city` | `""` or whitespace | `ValidationError::Empty` | | `zip` | `""` or whitespace | `ValidationError::Empty` | + +> **Serde:** serialises as a JSON object matching `PostalAddressInput` (the input struct). +> **TryFrom\<&str\>:** not implemented — the formatted multi-line string cannot be unambiguously decoded back to structured fields. diff --git a/docs/finance.md b/docs/finance.md index 8c9f37c..6313290 100644 --- a/docs/finance.md +++ b/docs/finance.md @@ -73,6 +73,16 @@ assert_eq!(money.currency().value(), "EUR"); | `currency()` | `&CurrencyCode` | `CurrencyCode("EUR")` | | `into_inner()` | `MoneyInput` | — | +### Arithmetic helpers + +All operations are immutable — they return a new `Money` or `Result`. + +| Method | Returns | Notes | +|---|---|---| +| `add(&Money)` | `Result` | Fails if currencies differ | +| `sub(&Money)` | `Result` | Fails if currencies differ; result may be negative | +| `neg()` | `Money` | Negates the amount; always succeeds | + --- ## Iban @@ -196,6 +206,7 @@ assert!(Percentage::new(f64::NAN).is_err()); | Method | Returns | Example | |---|---|---| | `value()` | `&f64` | `42.5` | +| `as_fraction()` | `f64` | `0.425` (divides by 100) | | `into_inner()` | `f64` | `42.5` | --- @@ -294,6 +305,7 @@ assert!(CardExpiryDate::new("01/20".into()).is_err()); // past | `value()` | `&String` | `"12/28"` | | `month()` | `u8` | `12` | | `year()` | `u16` | `2028` | +| `months_until()` | `u32` | full months remaining until expiry | | `into_inner()` | `String` | — | ### Errors diff --git a/docs/geo.md b/docs/geo.md index 415ff20..212ccd2 100644 --- a/docs/geo.md +++ b/docs/geo.md @@ -154,6 +154,7 @@ pub struct BoundingBoxInput { | `value()` | `&str` | `"SW: 48.0, 14.0 / NE: 51.0, 18.0"` | | `sw()` | `&Coordinate` | south-west corner | | `ne()` | `&Coordinate` | north-east corner | +| `contains(&Coordinate)` | `bool` | inclusive on all four edges | | `into_inner()` | `BoundingBoxInput` | original input | ### Errors diff --git a/docs/implementing.md b/docs/implementing.md index 87a6c07..37b4105 100644 --- a/docs/implementing.md +++ b/docs/implementing.md @@ -1,25 +1,22 @@ # Implementing custom value objects -You can implement the `ValueObject` trait for your own domain types. Use the existing types in `src/contact/` as reference implementations. +You can implement the `ValueObject` and `PrimitiveValue` traits for your own domain types. Use the existing types in `src/` as reference implementations. ## Simple value object -A simple VO wraps one raw primitive. `Input` and `Output` are the same type. +A simple VO wraps one raw primitive. Implement both `ValueObject` (construction + deconstruction) and `PrimitiveValue` (typed accessor). ```rust,ignore use arvo::errors::ValidationError; -use arvo::traits::ValueObject; +use arvo::traits::{PrimitiveValue, ValueObject}; -pub type PercentageInput = f64; -pub type PercentageOutput = f64; +pub type PercentageInput = f64; -#[derive(Debug, Clone, PartialEq)] pub struct Percentage(f64); impl ValueObject for Percentage { - type Input = PercentageInput; - type Output = PercentageOutput; - type Error = ValidationError; + type Input = f64; + type Error = ValidationError; fn new(value: f64) -> Result { if !(0.0..=100.0).contains(&value) { @@ -33,10 +30,14 @@ impl ValueObject for Percentage { Ok(Self(value)) } - fn value(&self) -> &f64 { &self.0 } fn into_inner(self) -> f64 { self.0 } } +impl PrimitiveValue for Percentage { + type Primitive = f64; + fn value(&self) -> &f64 { &self.0 } +} + impl TryFrom for Percentage { type Error = ValidationError; fn try_from(v: f64) -> Result { Self::new(v) } @@ -51,31 +52,25 @@ impl std::fmt::Display for Percentage { ## Composite value object -A composite VO accepts multiple typed inputs and returns a canonical representation. `Input` is a dedicated struct; `Output` is typically `String`. +A composite VO accepts multiple typed inputs. Implement only `ValueObject`. Expose data through dedicated accessor methods and `Display`. Provide `value()` as an inherent method returning the canonical string if useful. ```rust,ignore use arvo::errors::ValidationError; use arvo::traits::ValueObject; -// Dedicated input struct — one field per component -#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct CoordinateInput { pub latitude: f64, pub longitude: f64, } -pub type CoordinateOutput = String; // canonical: "48.8566,2.3522" - -#[derive(Debug, Clone, PartialEq)] pub struct Coordinate { - input: CoordinateInput, - canonical: String, + input: CoordinateInput, + canonical: String, } impl ValueObject for Coordinate { - type Input = CoordinateInput; - type Output = CoordinateOutput; - type Error = ValidationError; + type Input = CoordinateInput; + type Error = ValidationError; fn new(value: CoordinateInput) -> Result { if !(-90.0..=90.0).contains(&value.latitude) { @@ -84,33 +79,74 @@ impl ValueObject for Coordinate { if !(-180.0..=180.0).contains(&value.longitude) { return Err(ValidationError::invalid("Coordinate.longitude", &value.longitude.to_string())); } - let canonical = format!("{},{}", value.latitude, value.longitude); + let canonical = format!("{}, {}", value.latitude, value.longitude); Ok(Self { input: value, canonical }) } - fn value(&self) -> &String { &self.canonical } fn into_inner(self) -> CoordinateInput { self.input } } -// Extra accessors beyond the trait impl Coordinate { - pub fn latitude(&self) -> f64 { self.input.latitude } - pub fn longitude(&self) -> f64 { self.input.longitude } + pub fn value(&self) -> &str { &self.canonical } + pub fn latitude(&self) -> f64 { self.input.latitude } + pub fn longitude(&self) -> f64 { self.input.longitude } +} + +impl TryFrom<&str> for Coordinate { + type Error = ValidationError; + fn try_from(s: &str) -> Result { /* parse canonical */ todo!() } +} + +impl std::fmt::Display for Coordinate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.canonical) + } } ``` ## Checklist for every new type -- [ ] `type Input` and `type Output` type aliases defined and exported +- [ ] `type Input` type alias defined and exported - [ ] `#[derive(Debug, Clone, PartialEq, Eq, Hash)]` on the struct -- [ ] `#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]` -- [ ] `impl ValueObject` with `new`, `value`, `into_inner` -- [ ] `impl TryFrom<&str>` (for string-input types) +- [ ] Serde: `#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]` + + `serde(try_from = "T", into = "T")` so deserialisation validates via `new()` + + `impl TryFrom` delegating to `new()` and `#[cfg(feature = "serde")] impl From for T` +- [ ] `impl ValueObject` with `new` and `into_inner` +- [ ] For simple types: `impl PrimitiveValue` with `type Primitive` and `value()` +- [ ] For composite types: inherent `pub fn value(&self) -> &str` (if canonical string exists) +- [ ] `impl TryFrom<&str>` (for string-input types and composite types with reversible canonical format) - [ ] `impl Display` - [ ] Extra accessors for composite types -- [ ] Unit tests: valid input, empty/invalid input, normalisation, `try_from` +- [ ] Unit tests: valid input, empty/invalid input, normalisation, `try_from`, `serde_roundtrip`, `serde_deserialize_validates` - [ ] Doc comment with `# Example` block - [ ] Registered in `mod.rs` and `prelude` - [ ] Status updated in `ROADMAP.md` +## ORM / database integration + +arvo does not bundle database integration — this keeps dependencies minimal and lets you use any framework. Integrate using the accessors arvo already provides: + +**Raw sqlx:** +```rust,ignore +// Bind — extract the primitive +query.bind(email.value()) +query.bind(addr.street()) + +// Read back — construct via new() +let s: String = row.get("email"); +let email = EmailAddress::new(s)?; +``` + +**SeaORM / Diesel — composite types as multiple columns:** +```rust,ignore +// Define your own entity with individual columns +impl From for AddressModel { + fn from(addr: PostalAddress) -> Self { + let i = addr.into_inner(); + AddressModel { street: i.street, city: i.city, zip: i.zip, + country: i.country.into_inner() } + } +} +``` + See [CONTRIBUTING.md](../CONTRIBUTING.md) for the full development workflow. diff --git a/docs/net.md b/docs/net.md index 4785a0f..e6479a2 100644 --- a/docs/net.md +++ b/docs/net.md @@ -99,6 +99,8 @@ let ip: IpV4Address = "10.0.0.1".try_into()?; | Method | Returns | Example | |---|---|---| | `value()` | `&String` | `"192.168.1.1"` | +| `is_loopback()` | `bool` | `true` for `127.0.0.0/8` | +| `is_private()` | `bool` | `true` for `10/8`, `172.16/12`, `192.168/16` | | `into_inner()` | `String` | `"192.168.1.1"` | ### Errors @@ -186,6 +188,9 @@ assert!(Port::new(0).is_err()); | Method | Returns | Example | |---|---|---| | `value()` | `&u16` | `8080` | +| `is_well_known()` | `bool` | `true` for ports 1–1023 | +| `is_registered()` | `bool` | `true` for ports 1024–49151 | +| `is_ephemeral()` | `bool` | `true` for ports 49152–65535 | | `into_inner()` | `u16` | `8080` | ### Errors diff --git a/docs/primitives.md b/docs/primitives.md index d8c560c..a1e6f7a 100644 --- a/docs/primitives.md +++ b/docs/primitives.md @@ -270,6 +270,7 @@ let c: HexColor = "#1A2B3C".try_into()?; | `r()` | `u8` | `255` | | `g()` | `u8` | `0` | | `b()` | `u8` | `0` | +| `to_rgb()` | `(u8, u8, u8)` | `(255, 0, 0)` | | `into_inner()` | `String` | `"#FF0000"` | ### Errors @@ -306,6 +307,8 @@ assert_eq!(fr.value(), "fr"); | Method | Returns | Example | |---|---|---| | `value()` | `&String` | `"en-US"` | +| `language()` | `&str` | `"en"` (language subtag) | +| `region()` | `Option<&str>` | `Some("US")` / `None` for language-only tags | | `into_inner()` | `String` | `"en-US"` | ### Errors diff --git a/docs/temporal.md b/docs/temporal.md index 2a3240a..f78b565 100644 --- a/docs/temporal.md +++ b/docs/temporal.md @@ -31,6 +31,7 @@ assert!(UnixTimestamp::new(-1).is_err()); | Method | Returns | Example | |---|---|---| | `value()` | `&i64` | `1700000000` | +| `as_datetime()` | `DateTime` | converts to a chrono UTC datetime | | `into_inner()` | `i64` | `1700000000` | --- @@ -58,6 +59,7 @@ assert!(dob.age_years() > 0); |---|---|---| | `value()` | `&NaiveDate` | `1990-06-15` | | `age_years()` | `u32` | `35` | +| `is_minor()` | `bool` | `true` if age < 18 | | `into_inner()` | `NaiveDate` | — | ### Errors @@ -129,6 +131,8 @@ assert_eq!(range.duration().num_hours(), 2); | `start()` | `&DateTime` | — | | `end()` | `&DateTime` | — | | `duration()` | `Duration` | `2h` | +| `contains(&DateTime)` | `bool` | `[start, end)` — inclusive start, exclusive end | +| `overlaps(&TimeRange)` | `bool` | `true` if the two ranges share any instant | | `into_inner()` | `TimeRangeInput` | — | ### Errors @@ -170,6 +174,7 @@ assert_eq!(hours.duration().num_hours(), 8); | `open()` | `&NaiveTime` | `09:00` | | `close()` | `&NaiveTime` | `17:00` | | `duration()` | `Duration` | `8h` | +| `is_open_at(NaiveTime)` | `bool` | `[open, close)` — open inclusive, close exclusive | | `into_inner()` | `BusinessHoursInput` | — | ### Errors diff --git a/docs/value-objects.md b/docs/value-objects.md index 040bf8e..bc58dc8 100644 --- a/docs/value-objects.md +++ b/docs/value-objects.md @@ -18,46 +18,62 @@ fn send_email(address: EmailAddress) { /* always valid */ } ### Simple value objects -A simple VO wraps a single raw primitive. `Input` and `Output` are the same type. +A simple VO wraps a single raw primitive. Implements both `ValueObject` and `PrimitiveValue`. ``` "User@Example.COM" → EmailAddress("user@example.com") ↑ ↑ - Input Output (&String) + Input PrimitiveValue::value() → &String ``` -Examples: `EmailAddress`, `CountryCode`. +Examples: `EmailAddress`, `CountryCode`, `Latitude`, `Port`. ### Composite value objects -A composite VO is constructed from multiple typed inputs and returns a canonical representation. +A composite VO is constructed from multiple typed inputs and exposes data through dedicated accessor methods. Implements only `ValueObject`. ``` PhoneNumberInput { country_code: "CZ", number: "123456789" } ↓ PhoneNumber { e164: "+420123456789" } ↓ -value() → &String → "+420123456789" +value() → &str → "+420123456789" (inherent method) +calling_code() → &str → "+420" +number() → &str → "123456789" +country_code() → &CountryCode ``` -Examples: `PhoneNumber`. +Examples: `PhoneNumber`, `Money`, `PostalAddress`. -## The `ValueObject` trait - -All types implement the same interface: +## The trait hierarchy ```rust,ignore +// Base trait — all value objects pub trait ValueObject: Sized + Clone + PartialEq { - type Input; // what new() accepts - type Output: ?Sized; // what value() returns + type Input; type Error: std::error::Error; fn new(value: Self::Input) -> Result; - fn value(&self) -> &Self::Output; fn into_inner(self) -> Self::Input; } + +// Subtrait — simple single-primitive newtypes only +pub trait PrimitiveValue: ValueObject { + type Primitive: ?Sized; + fn value(&self) -> &Self::Primitive; +} ``` +Use `PrimitiveValue` as a bound when you need generic access to the inner value: + +```rust,ignore +fn print_value>(v: &T) { + println!("{}", v.value()); +} +``` + +For composite types, use the concrete type and its specific accessors. + ## Why immutability matters Once constructed, a value object never changes. There are no setters. If you need a different value, you construct a new instance. This means: diff --git a/src/contact/country_code.rs b/src/contact/country_code.rs index 2d33f63..c53d6b4 100644 --- a/src/contact/country_code.rs +++ b/src/contact/country_code.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CountryCode`] — a raw string before validation. pub type CountryCodeInput = String; -/// Output type for [`CountryCode`] — a normalised uppercase string. -pub type CountryCodeOutput = String; - /// A validated ISO 3166-1 alpha-2 country code. /// /// On construction the value is trimmed and uppercased, so `"cz"` and `"CZ"` @@ -27,12 +24,11 @@ pub type CountryCodeOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct CountryCode(String); impl ValueObject for CountryCode { type Input = CountryCodeInput; - type Output = CountryCodeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -48,16 +44,31 @@ impl ValueObject for CountryCode { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CountryCode { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} /// Allows ergonomic construction from a string literal: `"CZ".try_into()` +impl TryFrom for CountryCode { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CountryCode) -> String { + v.0 + } +} impl TryFrom<&str> for CountryCode { type Error = ValidationError; @@ -121,4 +132,20 @@ mod tests { let c: CountryCode = "DE".try_into().unwrap(); assert_eq!(c.value(), "DE"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = CountryCode::try_from("CZ").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: CountryCode = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/contact/email_address.rs b/src/contact/email_address.rs index 578c671..1c06bcb 100644 --- a/src/contact/email_address.rs +++ b/src/contact/email_address.rs @@ -1,20 +1,17 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; -use once_cell::sync::Lazy; +use crate::traits::{PrimitiveValue, ValueObject}; use regex::Regex; +use std::sync::LazyLock; /// Input type for [`EmailAddress`] — a raw string before validation. pub type EmailAddressInput = String; -/// Output type for [`EmailAddress`] — a normalised lowercase string. -pub type EmailAddressOutput = String; - /// Compiled email regex — evaluated once at first use. /// /// Pattern checks for a local part, `@`, a domain, and a TLD of at least /// 2 characters. Full RFC 5322 compliance is intentionally out of scope. -static EMAIL_REGEX: Lazy = - Lazy::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap()); +static EMAIL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]{2,}$").unwrap()); /// A validated, normalised email address. /// @@ -34,12 +31,11 @@ static EMAIL_REGEX: Lazy = /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct EmailAddress(String); impl ValueObject for EmailAddress { type Input = EmailAddressInput; - type Output = EmailAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -56,14 +52,16 @@ impl ValueObject for EmailAddress { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for EmailAddress { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl EmailAddress { /// Returns the local part of the email address (before `@`), e.g. `"user"`. @@ -78,6 +76,19 @@ impl EmailAddress { } /// Allows ergonomic construction from a string literal: `"a@b.com".try_into()` +impl TryFrom for EmailAddress { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: EmailAddress) -> String { + v.0 + } +} impl TryFrom<&str> for EmailAddress { type Error = ValidationError; @@ -143,4 +154,20 @@ mod tests { let e: EmailAddress = "hello@example.com".try_into().unwrap(); assert_eq!(e.value(), "hello@example.com"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = EmailAddress::try_from("user@example.com").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: EmailAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/contact/mod.rs b/src/contact/mod.rs index 441abc7..a871bca 100644 --- a/src/contact/mod.rs +++ b/src/contact/mod.rs @@ -5,10 +5,8 @@ mod phone_number; mod postal_address; mod website; -pub use country_code::CountryCode; -pub use email_address::EmailAddress; -pub use phone_number::PhoneNumber; -pub use postal_address::PostalAddress; -pub use postal_address::PostalAddressInput; -pub use website::Website; -pub use website::WebsiteInput; +pub use country_code::{CountryCode, CountryCodeInput}; +pub use email_address::{EmailAddress, EmailAddressInput}; +pub use phone_number::{PhoneNumber, PhoneNumberInput}; +pub use postal_address::{PostalAddress, PostalAddressInput}; +pub use website::{Website, WebsiteInput}; diff --git a/src/contact/phone_number.rs b/src/contact/phone_number.rs index 89f54d4..da5a164 100644 --- a/src/contact/phone_number.rs +++ b/src/contact/phone_number.rs @@ -1,7 +1,7 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; -use once_cell::sync::Lazy; +use crate::traits::{PrimitiveValue, ValueObject}; use regex::Regex; +use std::sync::LazyLock; use super::country_code::CountryCode; @@ -14,11 +14,8 @@ pub struct PhoneNumberInput { pub number: String, } -/// Output type for [`PhoneNumber`] — canonical E.164 string, e.g. `"+420123456789"`. -pub type PhoneNumberOutput = String; - /// Validates the local number part: digits only, 4–14 characters. -static NUMBER_REGEX: Lazy = Lazy::new(|| Regex::new(r"^\d{4,14}$").unwrap()); +static NUMBER_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^\d{4,14}$").unwrap()); /// A validated phone number stored in canonical E.164 format. /// @@ -61,7 +58,6 @@ impl From for String { impl ValueObject for PhoneNumber { type Input = PhoneNumberInput; - type Output = PhoneNumberOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -76,7 +72,8 @@ impl ValueObject for PhoneNumber { return Err(ValidationError::invalid("PhoneNumber", &number)); } - let prefix = calling_code(value.country_code.value()); + let prefix = calling_code(value.country_code.value()) + .ok_or_else(|| ValidationError::invalid("PhoneNumber", value.country_code.value()))?; let e164 = format!("{}{}", prefix, number); Ok(Self { @@ -88,19 +85,19 @@ impl ValueObject for PhoneNumber { }) } - fn value(&self) -> &Self::Output { - &self.e164 - } - fn into_inner(self) -> Self::Input { self.input } } impl PhoneNumber { + pub fn value(&self) -> &str { + &self.e164 + } + /// Returns the ITU calling code prefix, e.g. `"+420"`. pub fn calling_code(&self) -> &str { - calling_code(self.input.country_code.value()) + calling_code(self.input.country_code.value()).unwrap_or("+0") } /// Returns the local number digits without the calling code, e.g. `"123456789"`. @@ -121,10 +118,8 @@ impl std::fmt::Display for PhoneNumber { } } -/// Maps an ISO 3166-1 alpha-2 country code to its ITU calling code prefix. -/// Returns `"+0"` for unknown codes — callers should ensure valid CountryCode input. -fn calling_code(country: &str) -> &'static str { - match country { +fn calling_code(country: &str) -> Option<&'static str> { + Some(match country { "AF" => "+93", "AL" => "+355", "DZ" => "+213", @@ -375,14 +370,14 @@ fn calling_code(country: &str) -> &'static str { "WF" => "+681", "EH" => "+212", "KY" => "+1345", - _ => "+0", - } + _ => return None, + }) } #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn cz() -> CountryCode { CountryCode::new("CZ".into()).unwrap() diff --git a/src/contact/postal_address.rs b/src/contact/postal_address.rs index 21ca2a5..65dbdd4 100644 --- a/src/contact/postal_address.rs +++ b/src/contact/postal_address.rs @@ -1,10 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::country_code::CountryCode; /// Input type for [`PostalAddress`] construction. #[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct PostalAddressInput { /// Street name and number, e.g. `"Václavské náměstí 1"`. pub street: String, @@ -16,9 +17,6 @@ pub struct PostalAddressInput { pub country: CountryCode, } -/// Output type for [`PostalAddress`] — a human-readable multi-line string. -pub type PostalAddressOutput = String; - /// A validated postal address. /// /// All text fields (`street`, `city`, `zip`) must be non-empty after trimming. @@ -52,19 +50,20 @@ pub type PostalAddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + serde(try_from = "PostalAddressInput", into = "PostalAddressInput") +)] pub struct PostalAddress { street: String, city: String, zip: String, country: CountryCode, - /// Pre-computed display string. - #[cfg_attr(feature = "serde", serde(skip))] formatted: String, } impl ValueObject for PostalAddress { type Input = PostalAddressInput; - type Output = PostalAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -93,10 +92,6 @@ impl ValueObject for PostalAddress { }) } - fn value(&self) -> &Self::Output { - &self.formatted - } - fn into_inner(self) -> Self::Input { PostalAddressInput { street: self.street, @@ -108,6 +103,10 @@ impl ValueObject for PostalAddress { } impl PostalAddress { + pub fn value(&self) -> &str { + &self.formatted + } + /// Returns the street field, e.g. `"Václavské náměstí 1"`. pub fn street(&self) -> &str { &self.street @@ -129,6 +128,20 @@ impl PostalAddress { } } +impl TryFrom for PostalAddress { + type Error = ValidationError; + fn try_from(input: PostalAddressInput) -> Result { + Self::new(input) + } +} + +#[cfg(feature = "serde")] +impl From for PostalAddressInput { + fn from(a: PostalAddress) -> PostalAddressInput { + a.into_inner() + } +} + /// Displays the address in a human-readable multi-line format. impl std::fmt::Display for PostalAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -139,7 +152,7 @@ impl std::fmt::Display for PostalAddress { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn cz() -> CountryCode { CountryCode::new("CZ".into()).unwrap() @@ -233,4 +246,21 @@ mod tests { let b = PostalAddress::new(valid_input()).unwrap(); assert_eq!(a, b); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let addr = PostalAddress::new(valid_input()).unwrap(); + let json = serde_json::to_string(&addr).unwrap(); + let back: PostalAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(addr.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = + serde_json::from_str(r#"{"street":"","city":"Prague","zip":"110 00","country":"CZ"}"#); + assert!(result.is_err()); + } } diff --git a/src/contact/website.rs b/src/contact/website.rs index 3cab77a..9133629 100644 --- a/src/contact/website.rs +++ b/src/contact/website.rs @@ -1,13 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use url::Url; /// Input type for [`Website`] — a raw string before validation. pub type WebsiteInput = String; -/// Output type for [`Website`] — a normalised URL string. -pub type WebsiteOutput = String; - /// A validated website URL. /// /// Accepts `http` and `https` schemes only. On construction the value is @@ -28,12 +25,11 @@ pub type WebsiteOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Website(String); impl ValueObject for Website { type Input = WebsiteInput; - type Output = WebsiteOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -58,14 +54,16 @@ impl ValueObject for Website { Ok(Self(parsed.to_string())) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Website { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Website { /// Returns `true` if the scheme is `https`. @@ -85,6 +83,19 @@ impl Website { } /// Allows ergonomic construction from a string literal: `"https://example.com".try_into()` +impl TryFrom for Website { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Website) -> String { + v.0 + } +} impl TryFrom<&str> for Website { type Error = ValidationError; @@ -173,4 +184,20 @@ mod tests { let w: Website = "https://example.com".try_into().unwrap(); assert!(w.is_https()); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Website::try_from("https://example.com").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Website = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/bic.rs b/src/finance/bic.rs index 2aa50d6..52e902c 100644 --- a/src/finance/bic.rs +++ b/src/finance/bic.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Bic`]. pub type BicInput = String; -/// Output type for [`Bic`] — canonical uppercase string. -pub type BicOutput = String; - /// A validated BIC (Bank Identifier Code), also known as SWIFT code. /// /// On construction the input is trimmed and uppercased. A BIC is either 8 or @@ -33,12 +30,11 @@ pub type BicOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Bic(String); impl ValueObject for Bic { type Input = BicInput; - type Output = BicOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -72,14 +68,16 @@ impl ValueObject for Bic { Ok(Self(upper)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Bic { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Bic { /// Returns the 4-letter bank code (positions 1–4). @@ -107,6 +105,19 @@ impl Bic { } } +impl TryFrom for Bic { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Bic) -> String { + v.0 + } +} impl TryFrom<&str> for Bic { type Error = ValidationError; @@ -205,4 +216,20 @@ mod tests { let b: Bic = "DEUTDEDB".try_into().unwrap(); assert_eq!(b.value(), "DEUTDEDB"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Bic::try_from("DEUTDEDB").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Bic = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/card_expiry_date.rs b/src/finance/card_expiry_date.rs index 6fc9dbb..38eba7e 100644 --- a/src/finance/card_expiry_date.rs +++ b/src/finance/card_expiry_date.rs @@ -1,14 +1,11 @@ use chrono::{Datelike, Local}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CardExpiryDate`] — accepts `"MM/YY"` or `"MM/YYYY"`. pub type CardExpiryDateInput = String; -/// Output type for [`CardExpiryDate`] — normalised `"MM/YY"` string. -pub type CardExpiryDateOutput = String; - /// A validated credit/debit card expiry date. /// /// Accepts `"MM/YY"` or `"MM/YYYY"` format. The month must be 01–12 and the @@ -31,12 +28,11 @@ pub type CardExpiryDateOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct CardExpiryDate(String); impl ValueObject for CardExpiryDate { type Input = CardExpiryDateInput; - type Output = CardExpiryDateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -85,14 +81,16 @@ impl ValueObject for CardExpiryDate { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CardExpiryDate { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl CardExpiryDate { /// Returns the expiry month as a number (1–12). @@ -105,8 +103,31 @@ impl CardExpiryDate { let yy: u16 = self.0[3..].parse().unwrap(); 2000 + yy } + + /// Returns the number of full months from the current month until expiry. + pub fn months_until(&self) -> u32 { + let now = Local::now(); + let current_year = now.year() as u16; + let current_month = now.month() as u8; + let expiry_months = self.year() * 12 + self.month() as u16; + let current_months = current_year * 12 + current_month as u16; + expiry_months.saturating_sub(current_months) as u32 + } } +impl TryFrom for CardExpiryDate { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CardExpiryDate) -> String { + v.0 + } +} impl TryFrom<&str> for CardExpiryDate { type Error = ValidationError; diff --git a/src/finance/credit_card_number.rs b/src/finance/credit_card_number.rs index af51b9c..d27d0fe 100644 --- a/src/finance/credit_card_number.rs +++ b/src/finance/credit_card_number.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CreditCardNumber`]. pub type CreditCardNumberInput = String; -/// Output type for [`CreditCardNumber`] — digits only, no separators. -pub type CreditCardNumberOutput = String; - /// A validated credit card number using the Luhn algorithm. /// /// On construction spaces and hyphens are stripped; only digits are kept. @@ -29,12 +26,11 @@ pub type CreditCardNumberOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct CreditCardNumber(String); impl ValueObject for CreditCardNumber { type Input = CreditCardNumberInput; - type Output = CreditCardNumberOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -56,14 +52,16 @@ impl ValueObject for CreditCardNumber { Ok(Self(digits)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CreditCardNumber { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl CreditCardNumber { /// Returns the last 4 digits, e.g. `"0366"`. @@ -118,6 +116,27 @@ fn luhn_valid(digits: &str) -> bool { sum % 10 == 0 } +impl TryFrom for CreditCardNumber { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CreditCardNumber) -> String { + v.0 + } +} +impl TryFrom<&str> for CreditCardNumber { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/finance/currency_code.rs b/src/finance/currency_code.rs index d5bf968..69446fb 100644 --- a/src/finance/currency_code.rs +++ b/src/finance/currency_code.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CurrencyCode`]. pub type CurrencyCodeInput = String; -/// Output type for [`CurrencyCode`]. -pub type CurrencyCodeOutput = String; - /// Active ISO 4217 alphabetic currency codes, sorted for binary search. static ISO_4217: &[&str] = &[ "AED", "AFN", "ALL", "AMD", "ANG", "AOA", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", @@ -44,12 +41,11 @@ static ISO_4217: &[&str] = &[ /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct CurrencyCode(String); impl ValueObject for CurrencyCode { type Input = CurrencyCodeInput; - type Output = CurrencyCodeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -70,15 +66,30 @@ impl ValueObject for CurrencyCode { Ok(Self(upper)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for CurrencyCode { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for CurrencyCode { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CurrencyCode) -> String { + v.0 + } +} impl TryFrom<&str> for CurrencyCode { type Error = ValidationError; @@ -151,4 +162,20 @@ mod tests { let c: CurrencyCode = "GBP".try_into().unwrap(); assert_eq!(c.value(), "GBP"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = CurrencyCode::try_from("EUR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: CurrencyCode = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/exchange_rate.rs b/src/finance/exchange_rate.rs index d1d2167..77e4499 100644 --- a/src/finance/exchange_rate.rs +++ b/src/finance/exchange_rate.rs @@ -16,9 +16,6 @@ pub struct ExchangeRateInput { pub rate: Decimal, } -/// Output type for [`ExchangeRate`] — canonical `"/ "` string. -pub type ExchangeRateOutput = String; - /// A validated currency exchange rate. /// /// The `rate` must be strictly positive (> 0) and `from` must differ from `to`. @@ -43,17 +40,16 @@ pub type ExchangeRateOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct ExchangeRate { from: CurrencyCode, to: CurrencyCode, rate: Decimal, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for ExchangeRate { type Input = ExchangeRateInput; - type Output = ExchangeRateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -80,10 +76,6 @@ impl ValueObject for ExchangeRate { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { ExchangeRateInput { from: self.from, @@ -94,6 +86,10 @@ impl ValueObject for ExchangeRate { } impl ExchangeRate { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the source currency. pub fn from(&self) -> &CurrencyCode { &self.from @@ -110,6 +106,34 @@ impl ExchangeRate { } } +impl TryFrom<&str> for ExchangeRate { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("ExchangeRate", value); + let (pair_str, rate_str) = value.trim().split_once(' ').ok_or_else(err)?; + let (from_str, to_str) = pair_str.split_once('/').ok_or_else(err)?; + let from = CurrencyCode::new(from_str.to_owned()).map_err(|_| err())?; + let to = CurrencyCode::new(to_str.to_owned()).map_err(|_| err())?; + let rate: rust_decimal::Decimal = rate_str.trim().parse().map_err(|_| err())?; + Self::new(ExchangeRateInput { from, to, rate }) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: ExchangeRate) -> String { + v.canonical + } +} + +impl TryFrom for ExchangeRate { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for ExchangeRate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -119,7 +143,7 @@ impl std::fmt::Display for ExchangeRate { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn eur() -> CurrencyCode { CurrencyCode::new("EUR".into()).unwrap() @@ -220,4 +244,37 @@ mod tests { .unwrap(); assert_eq!(r.to_string(), r.value().to_owned()); } + + #[test] + fn try_from_parses_valid() { + let r = ExchangeRate::try_from("EUR/USD 1.0850").unwrap(); + assert_eq!(r.value(), "EUR/USD 1.0850"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(ExchangeRate::try_from("EURUSD1.0850").is_err()); + } + + #[test] + fn try_from_rejects_missing_slash() { + assert!(ExchangeRate::try_from("EURUSD 1.0850").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = ExchangeRate::try_from("EUR/USD 1.0850").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: ExchangeRate = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = ExchangeRate::try_from("EUR/USD 1.0850").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("EUR/USD 1.0850")); + } } diff --git a/src/finance/iban.rs b/src/finance/iban.rs index 59d6dc4..03a0e7e 100644 --- a/src/finance/iban.rs +++ b/src/finance/iban.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Iban`]. pub type IbanInput = String; -/// Output type for [`Iban`] — canonical uppercase string without spaces. -pub type IbanOutput = String; - /// A validated IBAN (International Bank Account Number). /// /// On construction all spaces are stripped and the value is uppercased. The @@ -27,12 +24,11 @@ pub type IbanOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Iban(String); impl ValueObject for Iban { type Input = IbanInput; - type Output = IbanOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -69,14 +65,16 @@ impl ValueObject for Iban { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Iban { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Iban { /// Returns the 2-letter country code, e.g. `"GB"`. @@ -95,6 +93,19 @@ impl Iban { } } +impl TryFrom for Iban { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Iban) -> String { + v.0 + } +} impl TryFrom<&str> for Iban { type Error = ValidationError; @@ -196,4 +207,20 @@ mod tests { let i: Iban = "GB82WEST12345698765432".try_into().unwrap(); assert_eq!(i.value(), "GB82WEST12345698765432"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Iban::try_from("GB82WEST12345698765432").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Iban = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/finance/mod.rs b/src/finance/mod.rs index 666a85a..3794f6e 100644 --- a/src/finance/mod.rs +++ b/src/finance/mod.rs @@ -8,12 +8,12 @@ mod money; mod percentage; mod vat_number; -pub use bic::{Bic, BicInput, BicOutput}; -pub use card_expiry_date::{CardExpiryDate, CardExpiryDateInput, CardExpiryDateOutput}; -pub use credit_card_number::{CreditCardNumber, CreditCardNumberInput, CreditCardNumberOutput}; -pub use currency_code::{CurrencyCode, CurrencyCodeInput, CurrencyCodeOutput}; -pub use exchange_rate::{ExchangeRate, ExchangeRateInput, ExchangeRateOutput}; -pub use iban::{Iban, IbanInput, IbanOutput}; -pub use money::{Money, MoneyInput, MoneyOutput}; -pub use percentage::{Percentage, PercentageInput, PercentageOutput}; -pub use vat_number::{VatNumber, VatNumberInput, VatNumberOutput}; +pub use bic::{Bic, BicInput}; +pub use card_expiry_date::{CardExpiryDate, CardExpiryDateInput}; +pub use credit_card_number::{CreditCardNumber, CreditCardNumberInput}; +pub use currency_code::{CurrencyCode, CurrencyCodeInput}; +pub use exchange_rate::{ExchangeRate, ExchangeRateInput}; +pub use iban::{Iban, IbanInput}; +pub use money::{Money, MoneyInput}; +pub use percentage::{Percentage, PercentageInput}; +pub use vat_number::{VatNumber, VatNumberInput}; diff --git a/src/finance/money.rs b/src/finance/money.rs index e79aa55..5c15903 100644 --- a/src/finance/money.rs +++ b/src/finance/money.rs @@ -14,9 +14,6 @@ pub struct MoneyInput { pub currency: CurrencyCode, } -/// Output type for [`Money`] — canonical `" "` string. -pub type MoneyOutput = String; - /// A validated monetary amount with an associated currency. /// /// `amount` may be any finite `Decimal` value; negative amounts represent debts. @@ -39,16 +36,15 @@ pub type MoneyOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Money { amount: Decimal, currency: CurrencyCode, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Money { type Input = MoneyInput; - type Output = MoneyOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -60,10 +56,6 @@ impl ValueObject for Money { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { MoneyInput { amount: self.amount, @@ -73,6 +65,10 @@ impl ValueObject for Money { } impl Money { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the monetary amount. pub fn amount(&self) -> &Decimal { &self.amount @@ -82,6 +78,77 @@ impl Money { pub fn currency(&self) -> &CurrencyCode { &self.currency } + + /// Returns the sum of `self` and `other`. Fails if currencies differ. + pub fn add(&self, other: &Money) -> Result { + if self.currency != other.currency { + return Err(ValidationError::invalid( + "Money", + &format!("cannot add {} and {}", self.currency, other.currency), + )); + } + let sum = self.amount + other.amount; + let canonical = format!("{:.2} {}", sum, self.currency); + Ok(Money { + amount: sum, + currency: self.currency.clone(), + canonical, + }) + } + + /// Returns the difference `self - other`. Fails if currencies differ. + pub fn sub(&self, other: &Money) -> Result { + if self.currency != other.currency { + return Err(ValidationError::invalid( + "Money", + &format!("cannot subtract {} and {}", self.currency, other.currency), + )); + } + let diff = self.amount - other.amount; + let canonical = format!("{:.2} {}", diff, self.currency); + Ok(Money { + amount: diff, + currency: self.currency.clone(), + canonical, + }) + } + + /// Returns the negation of this amount (e.g. a debt). + pub fn neg(&self) -> Money { + let negated = -self.amount; + let canonical = format!("{:.2} {}", negated, self.currency); + Money { + amount: negated, + currency: self.currency.clone(), + canonical, + } + } +} + +impl TryFrom<&str> for Money { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Money", value); + let (amount_str, currency_str) = value.trim().rsplit_once(' ').ok_or_else(err)?; + let amount: rust_decimal::Decimal = amount_str.trim().parse().map_err(|_| err())?; + let currency = CurrencyCode::new(currency_str.trim().to_owned()).map_err(|_| err())?; + Self::new(MoneyInput { amount, currency }) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Money) -> String { + v.canonical + } +} + +impl TryFrom for Money { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } } impl std::fmt::Display for Money { @@ -93,7 +160,7 @@ impl std::fmt::Display for Money { #[cfg(test)] mod tests { use super::*; - use crate::traits::ValueObject; + use crate::traits::{PrimitiveValue, ValueObject}; fn eur() -> CurrencyCode { CurrencyCode::new("EUR".into()).unwrap() @@ -173,6 +240,63 @@ mod tests { assert_eq!(m.to_string(), m.value().to_owned()); } + #[test] + fn add_same_currency() { + let a = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let b = Money::new(MoneyInput { + amount: "5.50".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let result = a.add(&b).unwrap(); + assert_eq!(result.value(), "15.50 EUR"); + } + + #[test] + fn add_different_currencies_fails() { + let a = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let b = Money::new(MoneyInput { + amount: "5.00".parse().unwrap(), + currency: usd(), + }) + .unwrap(); + assert!(a.add(&b).is_err()); + } + + #[test] + fn sub_same_currency() { + let a = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let b = Money::new(MoneyInput { + amount: "3.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + let result = a.sub(&b).unwrap(); + assert_eq!(result.value(), "7.00 EUR"); + } + + #[test] + fn neg_returns_negated_amount() { + let m = Money::new(MoneyInput { + amount: "10.00".parse().unwrap(), + currency: eur(), + }) + .unwrap(); + assert_eq!(m.neg().value(), "-10.00 EUR"); + } + #[test] fn into_inner_roundtrip() { let input = MoneyInput { @@ -182,4 +306,37 @@ mod tests { let m = Money::new(input.clone()).unwrap(); assert_eq!(m.into_inner(), input); } + + #[test] + fn try_from_parses_valid() { + let m = Money::try_from("10.50 EUR").unwrap(); + assert_eq!(m.value(), "10.50 EUR"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Money::try_from("10.50EUR").is_err()); + } + + #[test] + fn try_from_rejects_invalid_currency() { + assert!(Money::try_from("10.50 INVALID").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Money::try_from("10.50 EUR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Money = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Money::try_from("10.50 EUR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("10.50 EUR")); + } } diff --git a/src/finance/percentage.rs b/src/finance/percentage.rs index cda8302..0d17f29 100644 --- a/src/finance/percentage.rs +++ b/src/finance/percentage.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Percentage`]. pub type PercentageInput = f64; -/// Output type for [`Percentage`]. -pub type PercentageOutput = f64; - /// A validated percentage value in the range `0.0..=100.0`. /// /// The value must be finite (not NaN, not infinite) and within the inclusive @@ -27,12 +24,11 @@ pub type PercentageOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] pub struct Percentage(f64); impl ValueObject for Percentage { type Input = PercentageInput; - type Output = PercentageOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -47,12 +43,46 @@ impl ValueObject for Percentage { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Percentage { + type Primitive = f64; + fn value(&self) -> &f64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl Percentage { + /// Returns the value as a fraction in `0.0..=1.0` (e.g. `42.5` → `0.425`). + pub fn as_fraction(&self) -> f64 { + self.0 / 100.0 + } +} + +impl TryFrom for Percentage { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Percentage) -> f64 { + v.0 + } +} +impl TryFrom<&str> for Percentage { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Percentage", value))?; + Self::new(parsed) } } @@ -116,4 +146,37 @@ mod tests { let p = Percentage::new(33.3).unwrap(); assert_eq!(p.into_inner(), 33.3); } + + #[test] + fn try_from_parses_valid() { + let p = Percentage::try_from("42.5").unwrap(); + assert_eq!(*p.value(), 42.5); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Percentage::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Percentage::try_from("101.0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Percentage::new(42.5).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "42.5"); + let back: Percentage = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("101.0"); + assert!(result.is_err()); + } } diff --git a/src/finance/vat_number.rs b/src/finance/vat_number.rs index 5de87f8..a7b77e0 100644 --- a/src/finance/vat_number.rs +++ b/src/finance/vat_number.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`VatNumber`]. pub type VatNumberInput = String; -/// Output type for [`VatNumber`] — canonical uppercase string without spaces. -pub type VatNumberOutput = String; - /// EU VAT country prefixes (sorted for binary search). static EU_PREFIXES: &[&str] = &[ "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI", "FR", "HR", "HU", "IE", "IT", @@ -31,12 +28,11 @@ static EU_PREFIXES: &[&str] = &[ /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct VatNumber(String); impl ValueObject for VatNumber { type Input = VatNumberInput; - type Output = VatNumberOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -76,14 +72,16 @@ impl ValueObject for VatNumber { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for VatNumber { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl VatNumber { /// Returns the 2-letter EU country prefix, e.g. `"CZ"`. @@ -92,6 +90,19 @@ impl VatNumber { } } +impl TryFrom for VatNumber { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: VatNumber) -> String { + v.0 + } +} impl TryFrom<&str> for VatNumber { type Error = ValidationError; diff --git a/src/geo/bounding_box.rs b/src/geo/bounding_box.rs index 424601a..d68987e 100644 --- a/src/geo/bounding_box.rs +++ b/src/geo/bounding_box.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::Coordinate; @@ -38,16 +38,15 @@ pub struct BoundingBoxInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct BoundingBox { sw: Coordinate, ne: Coordinate, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for BoundingBox { type Input = BoundingBoxInput; - type Output = str; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -71,10 +70,6 @@ impl ValueObject for BoundingBox { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { BoundingBoxInput { sw: self.sw, @@ -84,6 +79,10 @@ impl ValueObject for BoundingBox { } impl BoundingBox { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the south-west corner. pub fn sw(&self) -> &Coordinate { &self.sw @@ -93,6 +92,44 @@ impl BoundingBox { pub fn ne(&self) -> &Coordinate { &self.ne } + + /// Returns `true` if `coord` lies within this bounding box (inclusive on all edges). + pub fn contains(&self, coord: &Coordinate) -> bool { + let lat = coord.lat().value(); + let lng = coord.lng().value(); + lat >= self.sw.lat().value() + && lat <= self.ne.lat().value() + && lng >= self.sw.lng().value() + && lng <= self.ne.lng().value() + } +} + +impl TryFrom<&str> for BoundingBox { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("BoundingBox", value); + let (sw_part, ne_part) = value.trim().split_once(" / ").ok_or_else(err)?; + let sw_str = sw_part.strip_prefix("SW: ").ok_or_else(err)?; + let ne_str = ne_part.strip_prefix("NE: ").ok_or_else(err)?; + let sw = Coordinate::try_from(sw_str).map_err(|_| err())?; + let ne = Coordinate::try_from(ne_str).map_err(|_| err())?; + Self::new(BoundingBoxInput { sw, ne }) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: BoundingBox) -> String { + v.canonical + } +} + +impl TryFrom for BoundingBox { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } } impl std::fmt::Display for BoundingBox { @@ -169,6 +206,37 @@ mod tests { assert_eq!(*bbox.ne().lng().value(), 18.0); } + #[test] + fn contains_inside() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(bbox.contains(&coord(50.0, 16.0))); + } + + #[test] + fn contains_on_edge() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(bbox.contains(&coord(48.0, 14.0))); + assert!(bbox.contains(&coord(51.0, 18.0))); + } + + #[test] + fn contains_outside() { + let bbox = BoundingBox::new(BoundingBoxInput { + sw: coord(48.0, 14.0), + ne: coord(51.0, 18.0), + }) + .unwrap(); + assert!(!bbox.contains(&coord(52.0, 16.0))); + } + #[test] fn display_matches_value() { let bbox = BoundingBox::new(BoundingBoxInput { @@ -178,4 +246,43 @@ mod tests { .unwrap(); assert_eq!(bbox.to_string(), bbox.value()); } + + #[test] + fn try_from_parses_valid() { + let bbox = + BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + assert!(bbox.value().starts_with("SW:")); + assert!(bbox.value().contains("NE:")); + } + + #[test] + fn try_from_rejects_missing_prefix() { + assert!(BoundingBox::try_from("48.0, 14.0 / 51.0, 18.0").is_err()); + } + + #[test] + fn try_from_rejects_sw_north_of_ne() { + assert!( + BoundingBox::try_from("SW: 52.000000, 14.000000 / NE: 51.000000, 18.000000").is_err() + ); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = + BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: BoundingBox = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = + BoundingBox::try_from("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("SW: 48.000000, 14.000000 / NE: 51.000000, 18.000000")); + } } diff --git a/src/geo/coordinate.rs b/src/geo/coordinate.rs index 0cf8999..8007890 100644 --- a/src/geo/coordinate.rs +++ b/src/geo/coordinate.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::{Latitude, Longitude}; @@ -31,16 +31,15 @@ pub struct CoordinateInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Coordinate { lat: Latitude, lng: Longitude, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Coordinate { type Input = CoordinateInput; - type Output = str; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -52,10 +51,6 @@ impl ValueObject for Coordinate { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { CoordinateInput { lat: self.lat, @@ -65,6 +60,10 @@ impl ValueObject for Coordinate { } impl Coordinate { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the latitude component. pub fn lat(&self) -> &Latitude { &self.lat @@ -76,6 +75,34 @@ impl Coordinate { } } +impl TryFrom<&str> for Coordinate { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Coordinate", value); + let (lat_str, lng_str) = value.trim().split_once(", ").ok_or_else(err)?; + let lat = + Latitude::new(lat_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; + let lng = + Longitude::new(lng_str.trim().parse::().map_err(|_| err())?).map_err(|_| err())?; + Self::new(CoordinateInput { lat, lng }) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Coordinate) -> String { + v.canonical + } +} + +impl TryFrom for Coordinate { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for Coordinate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -120,4 +147,37 @@ mod tests { assert_eq!(*inner.lat.value(), 48.858844); assert_eq!(*inner.lng.value(), 2.294351); } + + #[test] + fn try_from_parses_valid() { + let c = Coordinate::try_from("48.858844, 2.294351").unwrap(); + assert_eq!(c.value(), "48.858844, 2.294351"); + } + + #[test] + fn try_from_rejects_no_comma_separator() { + assert!(Coordinate::try_from("48.858844 2.294351").is_err()); + } + + #[test] + fn try_from_rejects_invalid_lat() { + assert!(Coordinate::try_from("91.0, 0.0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Coordinate::try_from("48.858844, 2.294351").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Coordinate = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Coordinate::try_from("48.858844, 2.294351").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("48.858844, 2.294351")); + } } diff --git a/src/geo/country_region.rs b/src/geo/country_region.rs index e4b404f..89f6eeb 100644 --- a/src/geo/country_region.rs +++ b/src/geo/country_region.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`CountryRegion`]. pub type CountryRegionInput = String; -/// Output type for [`CountryRegion`]. -pub type CountryRegionOutput = String; - /// A validated ISO 3166-2 subdivision code. /// /// **Format:** two uppercase ASCII letters (country code), a hyphen, then @@ -30,12 +27,11 @@ pub type CountryRegionOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct CountryRegion(String); impl ValueObject for CountryRegion { type Input = CountryRegionInput; - type Output = CountryRegionOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -52,14 +48,16 @@ impl ValueObject for CountryRegion { Ok(Self(upper)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for CountryRegion { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn is_valid_iso3166_2(s: &str) -> bool { let Some(dash) = s.find('-') else { @@ -93,6 +91,19 @@ impl CountryRegion { } } +impl TryFrom for CountryRegion { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: CountryRegion) -> String { + v.0 + } +} impl TryFrom<&str> for CountryRegion { type Error = ValidationError; @@ -170,4 +181,20 @@ mod tests { let r: CountryRegion = "DE-BY".try_into().unwrap(); assert_eq!(r.value(), "DE-BY"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = CountryRegion::try_from("CZ-PR").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: CountryRegion = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/geo/latitude.rs b/src/geo/latitude.rs index fcf3a67..4969052 100644 --- a/src/geo/latitude.rs +++ b/src/geo/latitude.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Latitude`]. pub type LatitudeInput = f64; -/// Output type for [`Latitude`]. -pub type LatitudeOutput = f64; - /// A validated geographic latitude in decimal degrees. /// /// The value must be finite and in the inclusive range `−90.0..=90.0`. @@ -25,12 +22,11 @@ pub type LatitudeOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] pub struct Latitude(f64); impl ValueObject for Latitude { type Input = LatitudeInput; - type Output = LatitudeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -43,12 +39,39 @@ impl ValueObject for Latitude { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Latitude { + type Primitive = f64; + fn value(&self) -> &f64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for Latitude { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Latitude) -> f64 { + v.0 + } +} +impl TryFrom<&str> for Latitude { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Latitude", value))?; + Self::new(parsed) } } @@ -102,4 +125,37 @@ mod tests { let lat = Latitude::new(51.5074).unwrap(); assert_eq!(lat.into_inner(), 51.5074); } + + #[test] + fn try_from_parses_valid() { + let lat = Latitude::try_from("48.8588").unwrap(); + assert_eq!(*lat.value(), 48.8588); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Latitude::try_from("not_a_number").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Latitude::try_from("91.0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Latitude::new(48.8588).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "48.8588"); + let back: Latitude = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("91.0"); + assert!(result.is_err()); + } } diff --git a/src/geo/longitude.rs b/src/geo/longitude.rs index 148b7c0..396a23f 100644 --- a/src/geo/longitude.rs +++ b/src/geo/longitude.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Longitude`]. pub type LongitudeInput = f64; -/// Output type for [`Longitude`]. -pub type LongitudeOutput = f64; - /// A validated geographic longitude in decimal degrees. /// /// The value must be finite and in the inclusive range `−180.0..=180.0`. @@ -25,12 +22,11 @@ pub type LongitudeOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] pub struct Longitude(f64); impl ValueObject for Longitude { type Input = LongitudeInput; - type Output = LongitudeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -43,12 +39,39 @@ impl ValueObject for Longitude { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Longitude { + type Primitive = f64; + fn value(&self) -> &f64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for Longitude { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Longitude) -> f64 { + v.0 + } +} +impl TryFrom<&str> for Longitude { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Longitude", value))?; + Self::new(parsed) } } @@ -102,4 +125,37 @@ mod tests { let lng = Longitude::new(-0.1278).unwrap(); assert_eq!(lng.into_inner(), -0.1278); } + + #[test] + fn try_from_parses_valid() { + let lng = Longitude::try_from("2.294351").unwrap(); + assert_eq!(*lng.value(), 2.294351); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Longitude::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Longitude::try_from("181.0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Longitude::new(2.2944).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "2.2944"); + let back: Longitude = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("181.0"); + assert!(result.is_err()); + } } diff --git a/src/geo/mod.rs b/src/geo/mod.rs index d109125..a7f6254 100644 --- a/src/geo/mod.rs +++ b/src/geo/mod.rs @@ -7,7 +7,7 @@ mod time_zone; pub use bounding_box::{BoundingBox, BoundingBoxInput}; pub use coordinate::{Coordinate, CoordinateInput}; -pub use country_region::CountryRegion; -pub use latitude::Latitude; -pub use longitude::Longitude; -pub use time_zone::TimeZone; +pub use country_region::{CountryRegion, CountryRegionInput}; +pub use latitude::{Latitude, LatitudeInput}; +pub use longitude::{Longitude, LongitudeInput}; +pub use time_zone::{TimeZone, TimeZoneInput}; diff --git a/src/geo/time_zone.rs b/src/geo/time_zone.rs index b71a913..04e3c36 100644 --- a/src/geo/time_zone.rs +++ b/src/geo/time_zone.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`TimeZone`]. pub type TimeZoneInput = String; -/// Output type for [`TimeZone`]. -pub type TimeZoneOutput = String; - /// Sorted list of canonical IANA timezone names. static IANA_TIMEZONES: &[&str] = &[ "Africa/Abidjan", @@ -456,12 +453,11 @@ static IANA_TIMEZONES: &[&str] = &[ /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct TimeZone(String); impl ValueObject for TimeZone { type Input = TimeZoneInput; - type Output = TimeZoneOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -478,15 +474,30 @@ impl ValueObject for TimeZone { Ok(Self(trimmed.to_owned())) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for TimeZone { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for TimeZone { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: TimeZone) -> String { + v.0 + } +} impl TryFrom<&str> for TimeZone { type Error = ValidationError; @@ -547,4 +558,20 @@ mod tests { let tz: TimeZone = "Asia/Tokyo".try_into().unwrap(); assert_eq!(tz.value(), "Asia/Tokyo"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = TimeZone::try_from("Europe/Prague").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: TimeZone = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/ean13.rs b/src/identifiers/ean13.rs index b128a0a..932c9d0 100644 --- a/src/identifiers/ean13.rs +++ b/src/identifiers/ean13.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Ean13`]. pub type Ean13Input = String; -/// Output type for [`Ean13`] — 13 bare digits. -pub type Ean13Output = String; - /// A validated EAN-13 barcode number. /// /// Spaces and hyphens are stripped on construction. The 13th digit is the @@ -25,7 +22,7 @@ pub type Ean13Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Ean13(String); fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { @@ -46,7 +43,6 @@ fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { impl ValueObject for Ean13 { type Input = Ean13Input; - type Output = Ean13Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -65,14 +61,16 @@ impl ValueObject for Ean13 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Ean13 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Ean13 { /// Returns the check digit (last digit). @@ -81,6 +79,19 @@ impl Ean13 { } } +impl TryFrom for Ean13 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Ean13) -> String { + v.0 + } +} impl TryFrom<&str> for Ean13 { type Error = ValidationError; @@ -132,4 +143,20 @@ mod tests { let e: Ean13 = "4006381333931".try_into().unwrap(); assert_eq!(e.value(), "4006381333931"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Ean13::try_from("5901234123457").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Ean13 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/ean8.rs b/src/identifiers/ean8.rs index 5caa7c5..3f84ce2 100644 --- a/src/identifiers/ean8.rs +++ b/src/identifiers/ean8.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Ean8`]. pub type Ean8Input = String; -/// Output type for [`Ean8`] — 8 bare digits. -pub type Ean8Output = String; - /// A validated EAN-8 barcode number. /// /// Spaces and hyphens are stripped on construction. The 8th digit is the @@ -25,7 +22,7 @@ pub type Ean8Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Ean8(String); fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { @@ -47,7 +44,6 @@ fn ean_checksum_valid(digits: &[u8], expected_len: usize) -> bool { impl ValueObject for Ean8 { type Input = Ean8Input; - type Output = Ean8Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -66,14 +62,16 @@ impl ValueObject for Ean8 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Ean8 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Ean8 { /// Returns the check digit (last digit). @@ -82,6 +80,19 @@ impl Ean8 { } } +impl TryFrom for Ean8 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Ean8) -> String { + v.0 + } +} impl TryFrom<&str> for Ean8 { type Error = ValidationError; @@ -133,4 +144,20 @@ mod tests { let e: Ean8 = "73513537".try_into().unwrap(); assert_eq!(e.value(), "73513537"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Ean8::try_from("96385074").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Ean8 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/isbn10.rs b/src/identifiers/isbn10.rs index 121f0cb..573ece7 100644 --- a/src/identifiers/isbn10.rs +++ b/src/identifiers/isbn10.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Isbn10`]. pub type Isbn10Input = String; -/// Output type for [`Isbn10`] — 10 characters (9 digits + check char `0–9` or `X`). -pub type Isbn10Output = String; - /// A validated ISBN-10 number. /// /// Hyphens and spaces are stripped on construction. The check character @@ -28,12 +25,11 @@ pub type Isbn10Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Isbn10(String); impl ValueObject for Isbn10 { type Input = Isbn10Input; - type Output = Isbn10Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -78,15 +74,30 @@ impl ValueObject for Isbn10 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Isbn10 { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for Isbn10 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Isbn10) -> String { + v.0 + } +} impl TryFrom<&str> for Isbn10 { type Error = ValidationError; @@ -144,4 +155,20 @@ mod tests { let i: Isbn10 = "0306406152".try_into().unwrap(); assert_eq!(i.value(), "0306406152"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Isbn10::try_from("0306406152").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Isbn10 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/isbn13.rs b/src/identifiers/isbn13.rs index 6fcc323..2917ad0 100644 --- a/src/identifiers/isbn13.rs +++ b/src/identifiers/isbn13.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Isbn13`]. pub type Isbn13Input = String; -/// Output type for [`Isbn13`] — 13 bare digits. -pub type Isbn13Output = String; - /// A validated ISBN-13 number. /// /// Hyphens and spaces are stripped on construction. Must start with `978` @@ -25,12 +22,11 @@ pub type Isbn13Output = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Isbn13(String); impl ValueObject for Isbn13 { type Input = Isbn13Input; - type Output = Isbn13Output; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -61,14 +57,16 @@ impl ValueObject for Isbn13 { Ok(Self(stripped)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Isbn13 { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Isbn13 { /// Returns the GS1 prefix — `"978"` or `"979"`. @@ -77,6 +75,19 @@ impl Isbn13 { } } +impl TryFrom for Isbn13 { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Isbn13) -> String { + v.0 + } +} impl TryFrom<&str> for Isbn13 { type Error = ValidationError; @@ -139,4 +150,20 @@ mod tests { let i: Isbn13 = "9780306406157".try_into().unwrap(); assert_eq!(i.value(), "9780306406157"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Isbn13::try_from("9780306406157").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Isbn13 = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/issn.rs b/src/identifiers/issn.rs index 562c784..f791bfb 100644 --- a/src/identifiers/issn.rs +++ b/src/identifiers/issn.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Issn`]. pub type IssnInput = String; -/// Output type for [`Issn`] — canonical `XXXX-XXXX` form. -pub type IssnOutput = String; - /// A validated ISSN (International Standard Serial Number). /// /// Spaces and hyphens are stripped on construction. The check character @@ -27,12 +24,11 @@ pub type IssnOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Issn(String); impl ValueObject for Issn { type Input = IssnInput; - type Output = IssnOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -77,15 +73,30 @@ impl ValueObject for Issn { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Issn { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for Issn { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Issn) -> String { + v.0 + } +} impl TryFrom<&str> for Issn { type Error = ValidationError; @@ -149,4 +160,20 @@ mod tests { let i: Issn = "0317-8471".try_into().unwrap(); assert_eq!(i.value(), "0317-8471"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Issn::try_from("0317-8471").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Issn = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/mod.rs b/src/identifiers/mod.rs index f848587..079cfcd 100644 --- a/src/identifiers/mod.rs +++ b/src/identifiers/mod.rs @@ -6,10 +6,10 @@ mod issn; mod slug; mod vin; -pub use ean8::{Ean8, Ean8Input, Ean8Output}; -pub use ean13::{Ean13, Ean13Input, Ean13Output}; -pub use isbn10::{Isbn10, Isbn10Input, Isbn10Output}; -pub use isbn13::{Isbn13, Isbn13Input, Isbn13Output}; -pub use issn::{Issn, IssnInput, IssnOutput}; -pub use slug::{Slug, SlugInput, SlugOutput}; -pub use vin::{Vin, VinInput, VinOutput}; +pub use ean8::{Ean8, Ean8Input}; +pub use ean13::{Ean13, Ean13Input}; +pub use isbn10::{Isbn10, Isbn10Input}; +pub use isbn13::{Isbn13, Isbn13Input}; +pub use issn::{Issn, IssnInput}; +pub use slug::{Slug, SlugInput}; +pub use vin::{Vin, VinInput}; diff --git a/src/identifiers/slug.rs b/src/identifiers/slug.rs index edea05d..af0cae1 100644 --- a/src/identifiers/slug.rs +++ b/src/identifiers/slug.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Slug`]. pub type SlugInput = String; -/// Output type for [`Slug`]. -pub type SlugOutput = String; - /// A URL-safe slug: lowercase alphanumeric characters and hyphens only. /// /// On construction the value is trimmed and lowercased. Consecutive hyphens @@ -26,12 +23,11 @@ pub type SlugOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Slug(String); impl ValueObject for Slug { type Input = SlugInput; - type Output = SlugOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -59,15 +55,30 @@ impl ValueObject for Slug { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Slug { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for Slug { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Slug) -> String { + v.0 + } +} impl TryFrom<&str> for Slug { type Error = ValidationError; @@ -140,4 +151,20 @@ mod tests { let s: Slug = "my-slug".try_into().unwrap(); assert_eq!(s.value(), "my-slug"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Slug::try_from("hello-world").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Slug = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/identifiers/vin.rs b/src/identifiers/vin.rs index 5c175b4..bef2ba9 100644 --- a/src/identifiers/vin.rs +++ b/src/identifiers/vin.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Vin`]. pub type VinInput = String; -/// Output type for [`Vin`] — 17 uppercase characters. -pub type VinOutput = String; - /// A validated Vehicle Identification Number (VIN) per ISO 3779. /// /// Trimmed and uppercased on construction. Must be exactly 17 characters @@ -27,7 +24,7 @@ pub type VinOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Vin(String); fn transliterate(c: char) -> Option { @@ -73,7 +70,6 @@ const WEIGHTS: [u32; 17] = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]; impl ValueObject for Vin { type Input = VinInput; - type Output = VinOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -115,14 +111,16 @@ impl ValueObject for Vin { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Vin { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Vin { /// World Manufacturer Identifier — first 3 characters. @@ -146,6 +144,19 @@ impl Vin { } } +impl TryFrom for Vin { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Vin) -> String { + v.0 + } +} impl TryFrom<&str> for Vin { type Error = ValidationError; @@ -232,4 +243,20 @@ mod tests { let v: Vin = VALID_VIN.try_into().unwrap(); assert_eq!(v.value(), VALID_VIN); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Vin::try_from("1HGBH41JXMN109186").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Vin = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/lib.rs b/src/lib.rs index aa599a3..200c0a3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,9 @@ //! arvo = { version = "0.9", features = ["contact", "finance"] } //! ``` //! -//! Available features: `contact`, `serde`, `full`. -//! See [ROADMAP.md](https://github.com/codegress-com/arvo/blob/main/ROADMAP.md) for planned modules. +//! Available features: `contact`, `finance`, `geo`, `identifiers`, `measurement`, `net`, +//! `primitives`, `temporal`, `serde`, `full`. +//! See [ROADMAP.md](https://github.com/codegress-com/arvo/blob/main/ROADMAP.md) for the full type list. //! //! ## Quick start //! @@ -31,8 +32,8 @@ //! //! // Composite value object — structured input, canonical output //! let phone = PhoneNumber::new(PhoneNumberInput { -//! country_code: CountryCode::new("CZ".into())?, -//! number: "123456789".into(), +//! country_code: CountryCode::new("CZ".into())?, +//! number: "123456789".into(), //! })?; //! assert_eq!(phone.value(), "+420123456789"); //! # Ok::<(), arvo::errors::ValidationError>(()) @@ -71,17 +72,60 @@ pub mod temporal; /// the most common value object types into scope without long paths. pub mod prelude { pub use crate::errors::ValidationError; - pub use crate::traits::ValueObject; + pub use crate::traits::{PrimitiveValue, ValueObject}; #[cfg(feature = "contact")] - pub use crate::contact::{CountryCode, EmailAddress}; + pub use crate::contact::{ + CountryCode, CountryCodeInput, EmailAddress, EmailAddressInput, PhoneNumber, + PhoneNumberInput, PostalAddress, PostalAddressInput, Website, WebsiteInput, + }; + + #[cfg(feature = "finance")] + pub use crate::finance::{ + Bic, BicInput, CardExpiryDate, CardExpiryDateInput, CreditCardNumber, + CreditCardNumberInput, CurrencyCode, CurrencyCodeInput, ExchangeRate, ExchangeRateInput, + Iban, IbanInput, Money, MoneyInput, Percentage, PercentageInput, VatNumber, VatNumberInput, + }; + + #[cfg(feature = "geo")] + pub use crate::geo::{ + BoundingBox, BoundingBoxInput, Coordinate, CoordinateInput, CountryRegion, + CountryRegionInput, Latitude, LatitudeInput, Longitude, LongitudeInput, TimeZone, + TimeZoneInput, + }; #[cfg(feature = "identifiers")] - pub use crate::identifiers::{Ean8, Ean13, Isbn10, Isbn13, Issn, Slug, Vin}; + pub use crate::identifiers::{ + Ean8, Ean8Input, Ean13, Ean13Input, Isbn10, Isbn10Input, Isbn13, Isbn13Input, Issn, + IssnInput, Slug, SlugInput, Vin, VinInput, + }; + + #[cfg(feature = "measurement")] + pub use crate::measurement::{ + Area, AreaInput, AreaUnit, Energy, EnergyInput, EnergyUnit, Frequency, FrequencyInput, + FrequencyUnit, Length, LengthInput, LengthUnit, Power, PowerInput, PowerUnit, Pressure, + PressureInput, PressureUnit, Speed, SpeedInput, SpeedUnit, Temperature, TemperatureInput, + TemperatureUnit, Volume, VolumeInput, VolumeUnit, Weight, WeightInput, WeightUnit, + }; + + #[cfg(feature = "net")] + pub use crate::net::{ + ApiKey, ApiKeyInput, Domain, DomainInput, HttpStatusCode, HttpStatusCodeInput, IpAddress, + IpAddressInput, IpV4Address, IpV4AddressInput, IpV6Address, IpV6AddressInput, MacAddress, + MacAddressInput, MimeType, MimeTypeInput, Port, PortInput, Url, UrlInput, + }; #[cfg(feature = "primitives")] pub use crate::primitives::{ - Base64String, BoundedString, HexColor, Locale, NonEmptyString, NonNegativeDecimal, - NonNegativeInt, PositiveDecimal, PositiveInt, Probability, + Base64String, Base64StringInput, BoundedString, HexColor, HexColorInput, Locale, + LocaleInput, NonEmptyString, NonEmptyStringInput, NonNegativeDecimal, + NonNegativeDecimalInput, NonNegativeInt, NonNegativeIntInput, PositiveDecimal, + PositiveDecimalInput, PositiveInt, PositiveIntInput, Probability, ProbabilityInput, + }; + + #[cfg(feature = "temporal")] + pub use crate::temporal::{ + BirthDate, BirthDateInput, BusinessHours, BusinessHoursInput, ExpiryDate, ExpiryDateInput, + TimeRange, TimeRangeInput, UnixTimestamp, UnixTimestampInput, }; } diff --git a/src/measurement/area.rs b/src/measurement/area.rs index 03edf4d..0224b33 100644 --- a/src/measurement/area.rs +++ b/src/measurement/area.rs @@ -14,6 +14,20 @@ pub enum AreaUnit { Ha, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Area) -> String { + v.canonical + } +} + +impl TryFrom for Area { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for AreaUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -50,16 +64,15 @@ pub struct AreaInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Area { value: f64, unit: AreaUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Area { type Input = AreaInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -74,9 +87,6 @@ impl ValueObject for Area { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { AreaInput { value: self.value, @@ -86,6 +96,10 @@ impl ValueObject for Area { } impl Area { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -94,6 +108,27 @@ impl Area { } } +impl TryFrom<&str> for Area { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Area", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "mm²" => AreaUnit::Mm2, + "cm²" => AreaUnit::Cm2, + "m²" => AreaUnit::M2, + "km²" => AreaUnit::Km2, + "in²" => AreaUnit::In2, + "ft²" => AreaUnit::Ft2, + "ha" => AreaUnit::Ha, + _ => return Err(err()), + }; + Self::new(AreaInput { value: val, unit }) + } +} + impl std::fmt::Display for Area { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -146,4 +181,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let a = Area::try_from("1.5 m²").unwrap(); + assert_eq!(a.value(), "1.5 m²"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Area::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Area::try_from("1.5 acres").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Area::try_from("1.5 m²").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Area = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Area::try_from("1.5 m²").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5")); + } } diff --git a/src/measurement/energy.rs b/src/measurement/energy.rs index 79141ea..ed5e933 100644 --- a/src/measurement/energy.rs +++ b/src/measurement/energy.rs @@ -13,6 +13,20 @@ pub enum EnergyUnit { Kcal, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Energy) -> String { + v.canonical + } +} + +impl TryFrom for Energy { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for EnergyUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -48,16 +62,15 @@ pub struct EnergyInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Energy { value: f64, unit: EnergyUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Energy { type Input = EnergyInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -72,9 +85,6 @@ impl ValueObject for Energy { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { EnergyInput { value: self.value, @@ -84,6 +94,10 @@ impl ValueObject for Energy { } impl Energy { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -92,6 +106,26 @@ impl Energy { } } +impl TryFrom<&str> for Energy { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Energy", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "J" => EnergyUnit::J, + "kJ" => EnergyUnit::KJ, + "MJ" => EnergyUnit::MJ, + "kWh" => EnergyUnit::KWh, + "cal" => EnergyUnit::Cal, + "kcal" => EnergyUnit::Kcal, + _ => return Err(err()), + }; + Self::new(EnergyInput { value: val, unit }) + } +} + impl std::fmt::Display for Energy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -144,4 +178,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let e = Energy::try_from("1.5 kJ").unwrap(); + assert_eq!(e.value(), "1.5 kJ"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Energy::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Energy::try_from("1.5 BTU").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Energy::try_from("1.5 kJ").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Energy = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Energy::try_from("1.5 kJ").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5 kJ")); + } } diff --git a/src/measurement/frequency.rs b/src/measurement/frequency.rs index a791d19..fbbdf79 100644 --- a/src/measurement/frequency.rs +++ b/src/measurement/frequency.rs @@ -11,6 +11,20 @@ pub enum FrequencyUnit { GHz, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Frequency) -> String { + v.canonical + } +} + +impl TryFrom for Frequency { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for FrequencyUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -44,16 +58,15 @@ pub struct FrequencyInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Frequency { value: f64, unit: FrequencyUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Frequency { type Input = FrequencyInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -71,9 +84,6 @@ impl ValueObject for Frequency { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { FrequencyInput { value: self.value, @@ -83,6 +93,10 @@ impl ValueObject for Frequency { } impl Frequency { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -91,6 +105,24 @@ impl Frequency { } } +impl TryFrom<&str> for Frequency { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Frequency", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "Hz" => FrequencyUnit::Hz, + "kHz" => FrequencyUnit::KHz, + "MHz" => FrequencyUnit::MHz, + "GHz" => FrequencyUnit::GHz, + _ => return Err(err()), + }; + Self::new(FrequencyInput { value: val, unit }) + } +} + impl std::fmt::Display for Frequency { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -143,4 +175,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let f = Frequency::try_from("2.4 GHz").unwrap(); + assert_eq!(f.value(), "2.4 GHz"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Frequency::try_from("2.4").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Frequency::try_from("2.4 THz").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Frequency::try_from("2.4 GHz").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Frequency = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Frequency::try_from("2.4 GHz").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("2.4 GHz")); + } } diff --git a/src/measurement/length.rs b/src/measurement/length.rs index b230bf1..4128b7a 100644 --- a/src/measurement/length.rs +++ b/src/measurement/length.rs @@ -13,6 +13,20 @@ pub enum LengthUnit { Ft, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Length) -> String { + v.canonical + } +} + +impl TryFrom for Length { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for LengthUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -49,16 +63,15 @@ pub struct LengthInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Length { value: f64, unit: LengthUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Length { type Input = LengthInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -73,10 +86,6 @@ impl ValueObject for Length { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { LengthInput { value: self.value, @@ -86,6 +95,10 @@ impl ValueObject for Length { } impl Length { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -94,6 +107,26 @@ impl Length { } } +impl TryFrom<&str> for Length { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Length", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "mm" => LengthUnit::Mm, + "cm" => LengthUnit::Cm, + "m" => LengthUnit::M, + "km" => LengthUnit::Km, + "in" => LengthUnit::In, + "ft" => LengthUnit::Ft, + _ => return Err(err()), + }; + Self::new(LengthInput { value: val, unit }) + } +} + impl std::fmt::Display for Length { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -161,4 +194,37 @@ mod tests { assert!(Length::new(LengthInput { value: 1.0, unit }).is_ok()); } } + + #[test] + fn try_from_parses_valid() { + let l = Length::try_from("1.5 km").unwrap(); + assert_eq!(l.value(), "1.5 km"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Length::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Length::try_from("1.5 parsec").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Length::try_from("1.5 km").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Length = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Length::try_from("1.5 km").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5 km")); + } } diff --git a/src/measurement/power.rs b/src/measurement/power.rs index aea0305..adb7ac2 100644 --- a/src/measurement/power.rs +++ b/src/measurement/power.rs @@ -11,6 +11,20 @@ pub enum PowerUnit { Hp, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Power) -> String { + v.canonical + } +} + +impl TryFrom for Power { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for PowerUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -44,16 +58,15 @@ pub struct PowerInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Power { value: f64, unit: PowerUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Power { type Input = PowerInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -68,9 +81,6 @@ impl ValueObject for Power { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { PowerInput { value: self.value, @@ -80,6 +90,10 @@ impl ValueObject for Power { } impl Power { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -88,6 +102,24 @@ impl Power { } } +impl TryFrom<&str> for Power { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Power", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "W" => PowerUnit::W, + "kW" => PowerUnit::KW, + "MW" => PowerUnit::MW, + "hp" => PowerUnit::Hp, + _ => return Err(err()), + }; + Self::new(PowerInput { value: val, unit }) + } +} + impl std::fmt::Display for Power { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -140,4 +172,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let p = Power::try_from("3.7 kW").unwrap(); + assert_eq!(p.value(), "3.7 kW"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Power::try_from("3.7").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Power::try_from("3.7 CVs").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Power::try_from("3.7 kW").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Power = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Power::try_from("3.7 kW").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("3.7 kW")); + } } diff --git a/src/measurement/pressure.rs b/src/measurement/pressure.rs index d883061..84631a5 100644 --- a/src/measurement/pressure.rs +++ b/src/measurement/pressure.rs @@ -13,6 +13,20 @@ pub enum PressureUnit { Atm, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Pressure) -> String { + v.canonical + } +} + +impl TryFrom for Pressure { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for PressureUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -48,16 +62,15 @@ pub struct PressureInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Pressure { value: f64, unit: PressureUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Pressure { type Input = PressureInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -75,9 +88,6 @@ impl ValueObject for Pressure { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { PressureInput { value: self.value, @@ -87,6 +97,10 @@ impl ValueObject for Pressure { } impl Pressure { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -95,6 +109,26 @@ impl Pressure { } } +impl TryFrom<&str> for Pressure { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Pressure", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "Pa" => PressureUnit::Pa, + "kPa" => PressureUnit::KPa, + "MPa" => PressureUnit::MPa, + "bar" => PressureUnit::Bar, + "psi" => PressureUnit::Psi, + "atm" => PressureUnit::Atm, + _ => return Err(err()), + }; + Self::new(PressureInput { value: val, unit }) + } +} + impl std::fmt::Display for Pressure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -147,4 +181,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let p = Pressure::try_from("101.325 kPa").unwrap(); + assert_eq!(p.value(), "101.325 kPa"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Pressure::try_from("101").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Pressure::try_from("1.0 hg").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Pressure::try_from("101.325 kPa").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Pressure = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Pressure::try_from("101.325 kPa").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("101.325 kPa")); + } } diff --git a/src/measurement/speed.rs b/src/measurement/speed.rs index 1d38f33..0aaf7c3 100644 --- a/src/measurement/speed.rs +++ b/src/measurement/speed.rs @@ -11,6 +11,20 @@ pub enum SpeedUnit { Kn, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Speed) -> String { + v.canonical + } +} + +impl TryFrom for Speed { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for SpeedUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -44,16 +58,15 @@ pub struct SpeedInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Speed { value: f64, unit: SpeedUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Speed { type Input = SpeedInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -68,9 +81,6 @@ impl ValueObject for Speed { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { SpeedInput { value: self.value, @@ -80,6 +90,10 @@ impl ValueObject for Speed { } impl Speed { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -88,6 +102,24 @@ impl Speed { } } +impl TryFrom<&str> for Speed { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Speed", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "m/s" => SpeedUnit::Ms, + "km/h" => SpeedUnit::Kmh, + "mph" => SpeedUnit::Mph, + "kn" => SpeedUnit::Kn, + _ => return Err(err()), + }; + Self::new(SpeedInput { value: val, unit }) + } +} + impl std::fmt::Display for Speed { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -140,4 +172,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let s = Speed::try_from("120 km/h").unwrap(); + assert_eq!(s.value(), "120 km/h"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Speed::try_from("120").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Speed::try_from("120 warp").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Speed::try_from("120 km/h").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Speed = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Speed::try_from("120 km/h").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("120 km/h")); + } } diff --git a/src/measurement/temperature.rs b/src/measurement/temperature.rs index 078e2b1..fc4b6ff 100644 --- a/src/measurement/temperature.rs +++ b/src/measurement/temperature.rs @@ -10,6 +10,20 @@ pub enum TemperatureUnit { Kelvin, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Temperature) -> String { + v.canonical + } +} + +impl TryFrom for Temperature { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for TemperatureUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -45,16 +59,15 @@ pub struct TemperatureInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Temperature { value: f64, unit: TemperatureUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Temperature { type Input = TemperatureInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -86,10 +99,6 @@ impl ValueObject for Temperature { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { TemperatureInput { value: self.value, @@ -99,6 +108,10 @@ impl ValueObject for Temperature { } impl Temperature { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -107,6 +120,23 @@ impl Temperature { } } +impl TryFrom<&str> for Temperature { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Temperature", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "°C" => TemperatureUnit::Celsius, + "°F" => TemperatureUnit::Fahrenheit, + "K" => TemperatureUnit::Kelvin, + _ => return Err(err()), + }; + Self::new(TemperatureInput { value: val, unit }) + } +} + impl std::fmt::Display for Temperature { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -192,4 +222,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let t = Temperature::try_from("100 °C").unwrap(); + assert_eq!(t.value(), "100 °C"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Temperature::try_from("100").is_err()); + } + + #[test] + fn try_from_rejects_below_absolute_zero() { + assert!(Temperature::try_from("-500 K").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Temperature::try_from("100 °C").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Temperature = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Temperature::try_from("100 °C").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("100")); + } } diff --git a/src/measurement/volume.rs b/src/measurement/volume.rs index f5948c5..8412f1d 100644 --- a/src/measurement/volume.rs +++ b/src/measurement/volume.rs @@ -12,6 +12,20 @@ pub enum VolumeUnit { Gal, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Volume) -> String { + v.canonical + } +} + +impl TryFrom for Volume { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for VolumeUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -46,16 +60,15 @@ pub struct VolumeInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Volume { value: f64, unit: VolumeUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Volume { type Input = VolumeInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -70,9 +83,6 @@ impl ValueObject for Volume { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } fn into_inner(self) -> Self::Input { VolumeInput { value: self.value, @@ -82,6 +92,10 @@ impl ValueObject for Volume { } impl Volume { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -90,6 +104,25 @@ impl Volume { } } +impl TryFrom<&str> for Volume { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Volume", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "ml" => VolumeUnit::Ml, + "l" => VolumeUnit::L, + "m³" => VolumeUnit::M3, + "fl oz" => VolumeUnit::FlOz, + "gal" => VolumeUnit::Gal, + _ => return Err(err()), + }; + Self::new(VolumeInput { value: val, unit }) + } +} + impl std::fmt::Display for Volume { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -142,4 +175,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let v = Volume::try_from("1.5 l").unwrap(); + assert_eq!(v.value(), "1.5 l"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Volume::try_from("1.5").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Volume::try_from("1.5 cups").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Volume::try_from("1.5 l").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Volume = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Volume::try_from("1.5 l").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("1.5 l")); + } } diff --git a/src/measurement/weight.rs b/src/measurement/weight.rs index 66bc956..e8e1355 100644 --- a/src/measurement/weight.rs +++ b/src/measurement/weight.rs @@ -13,6 +13,20 @@ pub enum WeightUnit { Lb, } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Weight) -> String { + v.canonical + } +} + +impl TryFrom for Weight { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } +} + impl std::fmt::Display for WeightUnit { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -48,16 +62,15 @@ pub struct WeightInput { /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Weight { value: f64, unit: WeightUnit, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for Weight { type Input = WeightInput; - type Output = str; type Error = ValidationError; fn new(input: Self::Input) -> Result { @@ -72,10 +85,6 @@ impl ValueObject for Weight { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { WeightInput { value: self.value, @@ -85,6 +94,10 @@ impl ValueObject for Weight { } impl Weight { + pub fn value(&self) -> &str { + &self.canonical + } + pub fn amount(&self) -> f64 { self.value } @@ -93,6 +106,26 @@ impl Weight { } } +impl TryFrom<&str> for Weight { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("Weight", value); + let (val_str, unit_str) = value.trim().split_once(' ').ok_or_else(err)?; + let val: f64 = val_str.trim().parse().map_err(|_| err())?; + let unit = match unit_str.trim() { + "mg" => WeightUnit::Mg, + "g" => WeightUnit::G, + "kg" => WeightUnit::Kg, + "t" => WeightUnit::T, + "oz" => WeightUnit::Oz, + "lb" => WeightUnit::Lb, + _ => return Err(err()), + }; + Self::new(WeightInput { value: val, unit }) + } +} + impl std::fmt::Display for Weight { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.canonical) @@ -146,4 +179,37 @@ mod tests { .is_err() ); } + + #[test] + fn try_from_parses_valid() { + let w = Weight::try_from("70 kg").unwrap(); + assert_eq!(w.value(), "70 kg"); + } + + #[test] + fn try_from_rejects_no_space() { + assert!(Weight::try_from("70").is_err()); + } + + #[test] + fn try_from_rejects_unknown_unit() { + assert!(Weight::try_from("70 stone").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Weight::try_from("70 kg").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Weight = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = Weight::try_from("70 kg").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("70 kg")); + } } diff --git a/src/net/api_key.rs b/src/net/api_key.rs index 0002a83..74e711a 100644 --- a/src/net/api_key.rs +++ b/src/net/api_key.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`ApiKey`]. pub type ApiKeyInput = String; -/// Output type for [`ApiKey`]. -pub type ApiKeyOutput = String; - /// A validated API key — non-empty, trimmed. /// /// `Display` shows a masked version with only the last 4 characters visible @@ -25,12 +22,11 @@ pub type ApiKeyOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct ApiKey(String); impl ValueObject for ApiKey { type Input = ApiKeyInput; - type Output = ApiKeyOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -43,14 +39,16 @@ impl ValueObject for ApiKey { Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for ApiKey { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl ApiKey { /// Returns the last 4 characters of the key. @@ -77,6 +75,27 @@ impl std::fmt::Display for ApiKey { } } +impl TryFrom for ApiKey { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: ApiKey) -> String { + v.0 + } +} +impl TryFrom<&str> for ApiKey { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + Self::new(value.to_owned()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -129,4 +148,20 @@ mod tests { assert!(displayed.ends_with("cret")); assert!(displayed.starts_with("**")); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = ApiKey::try_from("sk-test-1234567890abcdef").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: ApiKey = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"\""); + assert!(result.is_err()); + } } diff --git a/src/net/domain.rs b/src/net/domain.rs index 3086bbd..2d3172e 100644 --- a/src/net/domain.rs +++ b/src/net/domain.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Domain`]. pub type DomainInput = String; -/// Output type for [`Domain`]. -pub type DomainOutput = String; - /// A validated domain name without a scheme (e.g. `"example.com"`). /// /// **Normalisation:** trimmed, lowercased. @@ -27,12 +24,11 @@ pub type DomainOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Domain(String); impl ValueObject for Domain { type Input = DomainInput; - type Output = DomainOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -49,14 +45,16 @@ impl ValueObject for Domain { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Domain { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn is_valid_domain(s: &str) -> bool { if s.len() > 253 { @@ -84,6 +82,19 @@ fn is_valid_domain(s: &str) -> bool { true } +impl TryFrom for Domain { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Domain) -> String { + v.0 + } +} impl TryFrom<&str> for Domain { type Error = ValidationError; @@ -154,4 +165,20 @@ mod tests { let d: Domain = "example.org".try_into().unwrap(); assert_eq!(d.value(), "example.org"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Domain::try_from("example.com").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Domain = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/http_status_code.rs b/src/net/http_status_code.rs index 80a8e08..e6428c8 100644 --- a/src/net/http_status_code.rs +++ b/src/net/http_status_code.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`HttpStatusCode`]. pub type HttpStatusCodeInput = u16; -/// Output type for [`HttpStatusCode`]. -pub type HttpStatusCodeOutput = u16; - /// A validated HTTP status code in the range `100..=599`. /// /// # Example @@ -23,12 +20,11 @@ pub type HttpStatusCodeOutput = u16; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "u16", into = "u16"))] pub struct HttpStatusCode(u16); impl ValueObject for HttpStatusCode { type Input = HttpStatusCodeInput; - type Output = HttpStatusCodeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -41,14 +37,16 @@ impl ValueObject for HttpStatusCode { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for HttpStatusCode { + type Primitive = u16; + fn value(&self) -> &u16 { + &self.0 + } +} impl HttpStatusCode { /// Returns `true` for 1xx informational codes. @@ -77,6 +75,31 @@ impl HttpStatusCode { } } +impl TryFrom for HttpStatusCode { + type Error = ValidationError; + fn try_from(v: u16) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for u16 { + fn from(v: HttpStatusCode) -> u16 { + v.0 + } +} +impl TryFrom<&str> for HttpStatusCode { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("HttpStatusCode", value))?; + Self::new(parsed) + } +} + impl std::fmt::Display for HttpStatusCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -129,4 +152,37 @@ mod tests { let code = HttpStatusCode::new(201).unwrap(); assert_eq!(code.into_inner(), 201); } + + #[test] + fn try_from_parses_valid() { + let c = HttpStatusCode::try_from("200").unwrap(); + assert_eq!(*c.value(), 200); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(HttpStatusCode::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(HttpStatusCode::try_from("99").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = HttpStatusCode::new(200).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "200"); + let back: HttpStatusCode = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("99"); + assert!(result.is_err()); + } } diff --git a/src/net/ip_address.rs b/src/net/ip_address.rs index 51b2670..543bf00 100644 --- a/src/net/ip_address.rs +++ b/src/net/ip_address.rs @@ -1,14 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use super::{IpV4Address, IpV6Address}; /// Input for [`IpAddress`] — either a v4 or v6 address string. pub type IpAddressInput = String; -/// Output type for [`IpAddress`]. -pub type IpAddressOutput = String; - /// A validated IP address — either IPv4 or IPv6. /// /// Tries IPv4 first, then IPv6. The canonical string is stored normalised. @@ -29,12 +26,11 @@ pub type IpAddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct IpAddress(String); impl ValueObject for IpAddress { type Input = IpAddressInput; - type Output = IpAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -55,14 +51,16 @@ impl ValueObject for IpAddress { Err(ValidationError::invalid("IpAddress", trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for IpAddress { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl IpAddress { /// Returns `true` if the address is IPv4. @@ -76,6 +74,19 @@ impl IpAddress { } } +impl TryFrom for IpAddress { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: IpAddress) -> String { + v.0 + } +} impl TryFrom<&str> for IpAddress { type Error = ValidationError; diff --git a/src/net/ip_v4_address.rs b/src/net/ip_v4_address.rs index 5abe9b5..657df99 100644 --- a/src/net/ip_v4_address.rs +++ b/src/net/ip_v4_address.rs @@ -1,13 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use std::net::Ipv4Addr; /// Input type for [`IpV4Address`]. pub type IpV4AddressInput = String; -/// Output type for [`IpV4Address`]. -pub type IpV4AddressOutput = String; - /// A validated IPv4 address (e.g. `"192.168.1.1"`). /// /// **Normalisation:** trimmed. Leading zeros in octets are rejected @@ -26,12 +23,11 @@ pub type IpV4AddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct IpV4Address(String); impl ValueObject for IpV4Address { type Input = IpV4AddressInput; - type Output = IpV4AddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -54,15 +50,48 @@ impl ValueObject for IpV4Address { .map_err(|_| ValidationError::invalid("IpV4Address", trimmed)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for IpV4Address { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { +impl IpV4Address { + /// Returns `true` for loopback addresses (`127.0.0.0/8`). + pub fn is_loopback(&self) -> bool { self.0 + .parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) + } + + /// Returns `true` for private addresses (10/8, 172.16/12, 192.168/16). + pub fn is_private(&self) -> bool { + self.0 + .parse::() + .map(|ip| ip.is_private()) + .unwrap_or(false) + } +} + +impl TryFrom for IpV4Address { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: IpV4Address) -> String { + v.0 + } +} impl TryFrom<&str> for IpV4Address { type Error = ValidationError; @@ -127,9 +156,43 @@ mod tests { assert!(IpV4Address::new("::1".into()).is_err()); } + #[test] + fn is_loopback() { + assert!(IpV4Address::new("127.0.0.1".into()).unwrap().is_loopback()); + assert!( + !IpV4Address::new("192.168.1.1".into()) + .unwrap() + .is_loopback() + ); + } + + #[test] + fn is_private() { + assert!(IpV4Address::new("10.0.0.1".into()).unwrap().is_private()); + assert!(IpV4Address::new("172.16.0.1".into()).unwrap().is_private()); + assert!(IpV4Address::new("192.168.1.1".into()).unwrap().is_private()); + assert!(!IpV4Address::new("8.8.8.8".into()).unwrap().is_private()); + } + #[test] fn try_from_str() { let ip: IpV4Address = "10.0.0.1".try_into().unwrap(); assert_eq!(ip.value(), "10.0.0.1"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = IpV4Address::try_from("192.168.1.1").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: IpV4Address = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/ip_v6_address.rs b/src/net/ip_v6_address.rs index cd9fca4..65346aa 100644 --- a/src/net/ip_v6_address.rs +++ b/src/net/ip_v6_address.rs @@ -1,13 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use std::net::Ipv6Addr; /// Input type for [`IpV6Address`]. pub type IpV6AddressInput = String; -/// Output type for [`IpV6Address`]. -pub type IpV6AddressOutput = String; - /// A validated IPv6 address (e.g. `"2001:db8::1"`). /// /// **Normalisation:** trimmed; the address is stored in the canonical @@ -27,12 +24,11 @@ pub type IpV6AddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct IpV6Address(String); impl ValueObject for IpV6Address { type Input = IpV6AddressInput; - type Output = IpV6AddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -48,15 +44,30 @@ impl ValueObject for IpV6Address { .map_err(|_| ValidationError::invalid("IpV6Address", trimmed)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for IpV6Address { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for IpV6Address { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: IpV6Address) -> String { + v.0 + } +} impl TryFrom<&str> for IpV6Address { type Error = ValidationError; @@ -112,4 +123,20 @@ mod tests { let ip: IpV6Address = "::1".try_into().unwrap(); assert_eq!(ip.value(), "::1"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = IpV6Address::try_from("::1").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: IpV6Address = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/mac_address.rs b/src/net/mac_address.rs index 4ac7a1f..c8bbb26 100644 --- a/src/net/mac_address.rs +++ b/src/net/mac_address.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`MacAddress`]. pub type MacAddressInput = String; -/// Output type for [`MacAddress`]. -pub type MacAddressOutput = String; - /// A validated MAC address, normalised to lowercase colon-separated hex. /// /// **Normalisation:** accepts colon-separated (`AA:BB:CC:DD:EE:FF`), @@ -26,12 +23,11 @@ pub type MacAddressOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct MacAddress(String); impl ValueObject for MacAddress { type Input = MacAddressInput; - type Output = MacAddressOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -52,14 +48,16 @@ impl ValueObject for MacAddress { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for MacAddress { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn parse_mac_bytes(s: &str) -> Option<[u8; 6]> { // colon or hyphen separated: XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX @@ -106,6 +104,19 @@ fn parse_mac_bytes(s: &str) -> Option<[u8; 6]> { None } +impl TryFrom for MacAddress { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: MacAddress) -> String { + v.0 + } +} impl TryFrom<&str> for MacAddress { type Error = ValidationError; @@ -168,4 +179,20 @@ mod tests { let mac: MacAddress = "aa:bb:cc:dd:ee:ff".try_into().unwrap(); assert_eq!(mac.value(), "aa:bb:cc:dd:ee:ff"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = MacAddress::try_from("00:1A:2B:3C:4D:5E").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: MacAddress = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/mime_type.rs b/src/net/mime_type.rs index b19e828..c3bf7db 100644 --- a/src/net/mime_type.rs +++ b/src/net/mime_type.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`MimeType`]. pub type MimeTypeInput = String; -/// Output type for [`MimeType`]. -pub type MimeTypeOutput = String; - /// A validated MIME type (e.g. `"image/png"`, `"application/json"`). /// /// **Normalisation:** trimmed, lowercased. @@ -29,12 +26,11 @@ pub type MimeTypeOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct MimeType(String); impl ValueObject for MimeType { type Input = MimeTypeInput; - type Output = MimeTypeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -51,14 +47,16 @@ impl ValueObject for MimeType { Ok(Self(normalised)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for MimeType { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} fn is_valid_mime(s: &str) -> bool { // Split off optional parameters (; charset=utf-8) @@ -93,6 +91,19 @@ impl MimeType { } } +impl TryFrom for MimeType { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: MimeType) -> String { + v.0 + } +} impl TryFrom<&str> for MimeType { type Error = ValidationError; @@ -166,4 +177,20 @@ mod tests { let m: MimeType = "text/plain".try_into().unwrap(); assert_eq!(m.value(), "text/plain"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = MimeType::try_from("image/png").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: MimeType = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/net/mod.rs b/src/net/mod.rs index ee7dc8e..8e42aad 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -9,13 +9,13 @@ mod mime_type; mod port; mod url; -pub use api_key::ApiKey; -pub use domain::Domain; -pub use http_status_code::HttpStatusCode; -pub use ip_address::IpAddress; -pub use ip_v4_address::IpV4Address; -pub use ip_v6_address::IpV6Address; -pub use mac_address::MacAddress; -pub use mime_type::MimeType; -pub use port::Port; -pub use url::Url; +pub use api_key::{ApiKey, ApiKeyInput}; +pub use domain::{Domain, DomainInput}; +pub use http_status_code::{HttpStatusCode, HttpStatusCodeInput}; +pub use ip_address::{IpAddress, IpAddressInput}; +pub use ip_v4_address::{IpV4Address, IpV4AddressInput}; +pub use ip_v6_address::{IpV6Address, IpV6AddressInput}; +pub use mac_address::{MacAddress, MacAddressInput}; +pub use mime_type::{MimeType, MimeTypeInput}; +pub use port::{Port, PortInput}; +pub use url::{Url, UrlInput}; diff --git a/src/net/port.rs b/src/net/port.rs index bc40186..260d648 100644 --- a/src/net/port.rs +++ b/src/net/port.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Port`]. pub type PortInput = u16; -/// Output type for [`Port`]. -pub type PortOutput = u16; - /// A validated network port number in the range `1..=65535`. /// /// Port 0 is reserved and rejected. @@ -24,12 +21,11 @@ pub type PortOutput = u16; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "u16", into = "u16"))] pub struct Port(u16); impl ValueObject for Port { type Input = PortInput; - type Output = PortOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -39,12 +35,56 @@ impl ValueObject for Port { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Port { + type Primitive = u16; + fn value(&self) -> &u16 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl Port { + /// Returns `true` for well-known ports (1–1023). + pub fn is_well_known(&self) -> bool { + self.0 <= 1023 + } + + /// Returns `true` for registered ports (1024–49151). + pub fn is_registered(&self) -> bool { + (1024..=49151).contains(&self.0) + } + + /// Returns `true` for ephemeral / dynamic ports (49152–65535). + pub fn is_ephemeral(&self) -> bool { + self.0 >= 49152 + } +} + +impl TryFrom for Port { + type Error = ValidationError; + fn try_from(v: u16) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for u16 { + fn from(v: Port) -> u16 { + v.0 + } +} +impl TryFrom<&str> for Port { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Port", value))?; + Self::new(parsed) } } @@ -86,6 +126,15 @@ mod tests { assert!(Port::new(0).is_err()); } + #[test] + fn port_categories() { + assert!(Port::new(80).unwrap().is_well_known()); + assert!(Port::new(8080).unwrap().is_registered()); + assert!(Port::new(60000).unwrap().is_ephemeral()); + assert!(!Port::new(8080).unwrap().is_well_known()); + assert!(!Port::new(80).unwrap().is_ephemeral()); + } + #[test] fn display() { let port = Port::new(443).unwrap(); @@ -97,4 +146,37 @@ mod tests { let port = Port::new(3000).unwrap(); assert_eq!(port.into_inner(), 3000); } + + #[test] + fn try_from_parses_valid() { + let p = Port::try_from("8080").unwrap(); + assert_eq!(*p.value(), 8080); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Port::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_zero() { + assert!(Port::try_from("0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Port::new(8080).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "8080"); + let back: Port = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("0"); + assert!(result.is_err()); + } } diff --git a/src/net/url.rs b/src/net/url.rs index 514c9bd..f00569a 100644 --- a/src/net/url.rs +++ b/src/net/url.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Url`]. pub type UrlInput = String; -/// Output type for [`Url`]. -pub type UrlOutput = String; - /// A validated URL. Accepts `http`, `https`, `ftp`, `ftps`, `ws`, and `wss` schemes. /// Scheme and host are normalised to lowercase on construction. /// @@ -25,14 +22,13 @@ pub type UrlOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Url(String); const ALLOWED_SCHEMES: &[&str] = &["ftp", "ftps", "http", "https", "ws", "wss"]; impl ValueObject for Url { type Input = UrlInput; - type Output = UrlOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -59,14 +55,16 @@ impl ValueObject for Url { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Url { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Url { /// Returns the scheme, e.g. `"https"`. @@ -74,19 +72,40 @@ impl Url { self.0.split("://").next().unwrap_or("") } - /// Returns the host, e.g. `"example.com"`. + /// Returns the host without port, e.g. `"example.com"`. pub fn host(&self) -> &str { let after_scheme = self.0.split("://").nth(1).unwrap_or(""); - after_scheme + let host_and_port = after_scheme .split('/') .next() .unwrap_or("") .split('?') .next() - .unwrap_or("") + .unwrap_or(""); + if host_and_port.starts_with('[') { + // IPv6 literal: "[::1]:8080" → "[::1]" + if let Some(i) = host_and_port.find(']') { + return &host_and_port[..=i]; + } + return host_and_port; + } + host_and_port.split(':').next().unwrap_or(host_and_port) } } +impl TryFrom for Url { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Url) -> String { + v.0 + } +} impl TryFrom<&str> for Url { type Error = ValidationError; @@ -154,6 +173,12 @@ mod tests { assert!(Url::new("https://".into()).is_err()); } + #[test] + fn host_strips_port() { + let url = Url::new("https://example.com:8080/path".into()).unwrap(); + assert_eq!(url.host(), "example.com"); + } + #[test] fn try_from_str() { let url: Url = "https://example.com".try_into().unwrap(); diff --git a/src/primitives/base64_string.rs b/src/primitives/base64_string.rs index 0446f12..99653d7 100644 --- a/src/primitives/base64_string.rs +++ b/src/primitives/base64_string.rs @@ -1,14 +1,11 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD; /// Input type for [`Base64String`]. pub type Base64StringInput = String; -/// Output type for [`Base64String`]. -pub type Base64StringOutput = String; - /// A validated standard Base64-encoded string. /// /// Accepts the standard alphabet (`A–Z`, `a–z`, `0–9`, `+`, `/`) with `=` @@ -28,12 +25,11 @@ pub type Base64StringOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Base64String(String); impl ValueObject for Base64String { type Input = Base64StringInput; - type Output = Base64StringOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -47,14 +43,16 @@ impl ValueObject for Base64String { Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for Base64String { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl Base64String { /// Decodes the Base64 string and returns the raw bytes. @@ -63,6 +61,19 @@ impl Base64String { } } +impl TryFrom for Base64String { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Base64String) -> String { + v.0 + } +} impl TryFrom<&str> for Base64String { type Error = ValidationError; @@ -119,4 +130,20 @@ mod tests { let b: Base64String = "aGVsbG8=".try_into().unwrap(); assert_eq!(b.decode(), b"hello"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Base64String::try_from("aGVsbG8=").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Base64String = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/bounded_string.rs b/src/primitives/bounded_string.rs index 2cf21b7..76d263f 100644 --- a/src/primitives/bounded_string.rs +++ b/src/primitives/bounded_string.rs @@ -1,5 +1,5 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// A string whose length (in Unicode characters) is constrained to `MIN..=MAX`. /// @@ -24,12 +24,11 @@ use crate::traits::ValueObject; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct BoundedString(String); impl ValueObject for BoundedString { type Input = String; - type Output = String; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -52,12 +51,30 @@ impl ValueObject for BoundedString Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} + +impl PrimitiveValue for BoundedString { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for BoundedString { + type Error = ValidationError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +#[cfg(feature = "serde")] +impl From> for String { + fn from(v: BoundedString) -> String { + v.0 } } diff --git a/src/primitives/hex_color.rs b/src/primitives/hex_color.rs index 669eed7..21f1ac6 100644 --- a/src/primitives/hex_color.rs +++ b/src/primitives/hex_color.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`HexColor`]. pub type HexColorInput = String; -/// Output type for [`HexColor`] — always a 7-character `#RRGGBB` string. -pub type HexColorOutput = String; - /// A CSS hex color in canonical `#RRGGBB` form, normalised to uppercase. /// /// Accepts both 6-digit (`#FF0000`) and 3-digit shorthand (`#F00`) input. @@ -27,12 +24,11 @@ pub type HexColorOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct HexColor(String); impl ValueObject for HexColor { type Input = HexColorInput; - type Output = HexColorOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -66,14 +62,16 @@ impl ValueObject for HexColor { Ok(Self(expanded)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for HexColor { + type Primitive = String; + fn value(&self) -> &String { + &self.0 + } +} impl HexColor { fn channel(s: &str, offset: usize) -> u8 { @@ -94,8 +92,26 @@ impl HexColor { pub fn b(&self) -> u8 { Self::channel(&self.0, 5) } + + /// Returns the RGB channels as a tuple `(r, g, b)`. + pub fn to_rgb(&self) -> (u8, u8, u8) { + (self.r(), self.g(), self.b()) + } } +impl TryFrom for HexColor { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: HexColor) -> String { + v.0 + } +} impl TryFrom<&str> for HexColor { type Error = ValidationError; @@ -171,4 +187,20 @@ mod tests { let c: HexColor = "#ABC".try_into().unwrap(); assert_eq!(c.value(), "#AABBCC"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = HexColor::try_from("#ff0000").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: HexColor = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/locale.rs b/src/primitives/locale.rs index 7789746..1ab14a2 100644 --- a/src/primitives/locale.rs +++ b/src/primitives/locale.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Locale`]. pub type LocaleInput = String; -/// Output type for [`Locale`] — BCP 47 canonical form, e.g. `"en-US"`. -pub type LocaleOutput = String; - /// A BCP 47 language tag (e.g. `"en-US"`, `"cs-CZ"`, `"fr"`). /// /// Accepts both `-` and `_` as separators. On construction, the language @@ -30,12 +27,11 @@ pub type LocaleOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct Locale(String); impl ValueObject for Locale { type Input = LocaleInput; - type Output = LocaleOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -69,15 +65,44 @@ impl ValueObject for Locale { Ok(Self(canonical)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Locale { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl Locale { + /// Returns the language subtag, e.g. `"en"` from `"en-US"`. + pub fn language(&self) -> &str { + self.0.split('-').next().unwrap_or(&self.0) + } + + /// Returns the region subtag if present, e.g. `Some("US")` from `"en-US"`. + pub fn region(&self) -> Option<&str> { + let mut parts = self.0.splitn(2, '-'); + parts.next(); + parts.next() + } +} + +impl TryFrom for Locale { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: Locale) -> String { + v.0 + } +} impl TryFrom<&str> for Locale { type Error = ValidationError; @@ -152,9 +177,44 @@ mod tests { assert!(Locale::new(String::new()).is_err()); } + #[test] + fn language_subtag() { + let l = Locale::new("en-US".into()).unwrap(); + assert_eq!(l.language(), "en"); + } + + #[test] + fn language_only_locale() { + let l = Locale::new("fr".into()).unwrap(); + assert_eq!(l.language(), "fr"); + assert_eq!(l.region(), None); + } + + #[test] + fn region_subtag() { + let l = Locale::new("cs-CZ".into()).unwrap(); + assert_eq!(l.region(), Some("CZ")); + } + #[test] fn try_from_str() { let l: Locale = "cs-CZ".try_into().unwrap(); assert_eq!(l.value(), "cs-CZ"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Locale::try_from("en-US").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: Locale = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"__invalid__\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/mod.rs b/src/primitives/mod.rs index 8d45a4c..607e739 100644 --- a/src/primitives/mod.rs +++ b/src/primitives/mod.rs @@ -9,15 +9,13 @@ mod positive_decimal; mod positive_int; mod probability; -pub use base64_string::{Base64String, Base64StringInput, Base64StringOutput}; +pub use base64_string::{Base64String, Base64StringInput}; pub use bounded_string::BoundedString; -pub use hex_color::{HexColor, HexColorInput, HexColorOutput}; -pub use locale::{Locale, LocaleInput, LocaleOutput}; -pub use non_empty_string::{NonEmptyString, NonEmptyStringInput, NonEmptyStringOutput}; -pub use non_negative_decimal::{ - NonNegativeDecimal, NonNegativeDecimalInput, NonNegativeDecimalOutput, -}; -pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput, NonNegativeIntOutput}; -pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput, PositiveDecimalOutput}; -pub use positive_int::{PositiveInt, PositiveIntInput, PositiveIntOutput}; -pub use probability::{Probability, ProbabilityInput, ProbabilityOutput}; +pub use hex_color::{HexColor, HexColorInput}; +pub use locale::{Locale, LocaleInput}; +pub use non_empty_string::{NonEmptyString, NonEmptyStringInput}; +pub use non_negative_decimal::{NonNegativeDecimal, NonNegativeDecimalInput}; +pub use non_negative_int::{NonNegativeInt, NonNegativeIntInput}; +pub use positive_decimal::{PositiveDecimal, PositiveDecimalInput}; +pub use positive_int::{PositiveInt, PositiveIntInput}; +pub use probability::{Probability, ProbabilityInput}; diff --git a/src/primitives/non_empty_string.rs b/src/primitives/non_empty_string.rs index 925b71d..ba8e153 100644 --- a/src/primitives/non_empty_string.rs +++ b/src/primitives/non_empty_string.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`NonEmptyString`]. pub type NonEmptyStringInput = String; -/// Output type for [`NonEmptyString`]. -pub type NonEmptyStringOutput = String; - /// A non-empty, trimmed string. /// /// Surrounding whitespace is stripped on construction. A string that consists @@ -25,12 +22,11 @@ pub type NonEmptyStringOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct NonEmptyString(String); impl ValueObject for NonEmptyString { type Input = NonEmptyStringInput; - type Output = NonEmptyStringOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -41,15 +37,30 @@ impl ValueObject for NonEmptyString { Ok(Self(trimmed)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for NonEmptyString { + type Primitive = String; + fn value(&self) -> &String { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for NonEmptyString { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::new(s) } } +#[cfg(feature = "serde")] +impl From for String { + fn from(v: NonEmptyString) -> String { + v.0 + } +} impl TryFrom<&str> for NonEmptyString { type Error = ValidationError; @@ -95,4 +106,20 @@ mod tests { let s: NonEmptyString = "world".try_into().unwrap(); assert_eq!(s.value(), "world"); } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = NonEmptyString::try_from("hello").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: NonEmptyString = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/non_negative_decimal.rs b/src/primitives/non_negative_decimal.rs index 1d76560..2cc9470 100644 --- a/src/primitives/non_negative_decimal.rs +++ b/src/primitives/non_negative_decimal.rs @@ -1,13 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use rust_decimal::Decimal; /// Input type for [`NonNegativeDecimal`]. pub type NonNegativeDecimalInput = Decimal; -/// Output type for [`NonNegativeDecimal`]. -pub type NonNegativeDecimalOutput = Decimal; - /// A non-negative decimal number (`Decimal >= 0`). /// /// Negative values are rejected on construction. Zero is allowed. @@ -26,12 +23,11 @@ pub type NonNegativeDecimalOutput = Decimal; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "Decimal", into = "Decimal"))] pub struct NonNegativeDecimal(Decimal); impl ValueObject for NonNegativeDecimal { type Input = NonNegativeDecimalInput; - type Output = NonNegativeDecimalOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -46,12 +42,39 @@ impl ValueObject for NonNegativeDecimal { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for NonNegativeDecimal { + type Primitive = Decimal; + fn value(&self) -> &Decimal { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for NonNegativeDecimal { + type Error = ValidationError; + fn try_from(v: Decimal) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for Decimal { + fn from(v: NonNegativeDecimal) -> Decimal { + v.0 + } +} +impl TryFrom<&str> for NonNegativeDecimal { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("NonNegativeDecimal", value))?; + Self::new(parsed) } } @@ -82,4 +105,36 @@ mod tests { fn rejects_negative() { assert!(NonNegativeDecimal::new(Decimal::from_str("-0.01").unwrap()).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = NonNegativeDecimal::try_from("0.00").unwrap(); + assert_eq!(v.value().to_string(), "0.00"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(NonNegativeDecimal::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_negative() { + assert!(NonNegativeDecimal::try_from("-1").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = NonNegativeDecimal::try_from("0.00").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: NonNegativeDecimal = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"-1\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/non_negative_int.rs b/src/primitives/non_negative_int.rs index 926f8b4..0be4bf8 100644 --- a/src/primitives/non_negative_int.rs +++ b/src/primitives/non_negative_int.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`NonNegativeInt`]. pub type NonNegativeIntInput = i64; -/// Output type for [`NonNegativeInt`]. -pub type NonNegativeIntOutput = i64; - /// A non-negative integer (`i64 >= 0`). /// /// Negative values are rejected on construction. Zero is allowed. @@ -24,12 +21,11 @@ pub type NonNegativeIntOutput = i64; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] pub struct NonNegativeInt(i64); impl ValueObject for NonNegativeInt { type Input = NonNegativeIntInput; - type Output = NonNegativeIntOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -44,12 +40,39 @@ impl ValueObject for NonNegativeInt { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for NonNegativeInt { + type Primitive = i64; + fn value(&self) -> &i64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for NonNegativeInt { + type Error = ValidationError; + fn try_from(v: i64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for i64 { + fn from(v: NonNegativeInt) -> i64 { + v.0 + } +} +impl TryFrom<&str> for NonNegativeInt { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("NonNegativeInt", value))?; + Self::new(parsed) } } @@ -79,4 +102,37 @@ mod tests { fn rejects_negative() { assert!(NonNegativeInt::new(-1).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = NonNegativeInt::try_from("0").unwrap(); + assert_eq!(*v.value(), 0); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(NonNegativeInt::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_negative() { + assert!(NonNegativeInt::try_from("-1").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = NonNegativeInt::new(0).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "0"); + let back: NonNegativeInt = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("-1"); + assert!(result.is_err()); + } } diff --git a/src/primitives/positive_decimal.rs b/src/primitives/positive_decimal.rs index 63e8c0b..f7a28a2 100644 --- a/src/primitives/positive_decimal.rs +++ b/src/primitives/positive_decimal.rs @@ -1,13 +1,10 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; use rust_decimal::Decimal; /// Input type for [`PositiveDecimal`]. pub type PositiveDecimalInput = Decimal; -/// Output type for [`PositiveDecimal`]. -pub type PositiveDecimalOutput = Decimal; - /// A strictly positive decimal number (`Decimal > 0`). /// /// Zero and negative values are rejected on construction. @@ -26,12 +23,11 @@ pub type PositiveDecimalOutput = Decimal; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "Decimal", into = "Decimal"))] pub struct PositiveDecimal(Decimal); impl ValueObject for PositiveDecimal { type Input = PositiveDecimalInput; - type Output = PositiveDecimalOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -46,12 +42,39 @@ impl ValueObject for PositiveDecimal { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for PositiveDecimal { + type Primitive = Decimal; + fn value(&self) -> &Decimal { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for PositiveDecimal { + type Error = ValidationError; + fn try_from(v: Decimal) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for Decimal { + fn from(v: PositiveDecimal) -> Decimal { + v.0 + } +} +impl TryFrom<&str> for PositiveDecimal { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("PositiveDecimal", value))?; + Self::new(parsed) } } @@ -81,4 +104,36 @@ mod tests { fn rejects_negative() { assert!(PositiveDecimal::new(Decimal::from_str("-1").unwrap()).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = PositiveDecimal::try_from("3.14").unwrap(); + assert_eq!(v.value().to_string(), "3.14"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(PositiveDecimal::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_zero() { + assert!(PositiveDecimal::try_from("0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = PositiveDecimal::try_from("3.14").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: PositiveDecimal = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"0\""); + assert!(result.is_err()); + } } diff --git a/src/primitives/positive_int.rs b/src/primitives/positive_int.rs index b589415..89886bb 100644 --- a/src/primitives/positive_int.rs +++ b/src/primitives/positive_int.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`PositiveInt`]. pub type PositiveIntInput = i64; -/// Output type for [`PositiveInt`]. -pub type PositiveIntOutput = i64; - /// A strictly positive integer (`i64 > 0`). /// /// Zero and negative values are rejected on construction. @@ -25,12 +22,11 @@ pub type PositiveIntOutput = i64; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] pub struct PositiveInt(i64); impl ValueObject for PositiveInt { type Input = PositiveIntInput; - type Output = PositiveIntOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -45,12 +41,39 @@ impl ValueObject for PositiveInt { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for PositiveInt { + type Primitive = i64; + fn value(&self) -> &i64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for PositiveInt { + type Error = ValidationError; + fn try_from(v: i64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for i64 { + fn from(v: PositiveInt) -> i64 { + v.0 + } +} +impl TryFrom<&str> for PositiveInt { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("PositiveInt", value))?; + Self::new(parsed) } } @@ -85,4 +108,37 @@ mod tests { fn rejects_negative() { assert!(PositiveInt::new(-1).is_err()); } + + #[test] + fn try_from_parses_valid() { + let v = PositiveInt::try_from("42").unwrap(); + assert_eq!(*v.value(), 42); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(PositiveInt::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_zero() { + assert!(PositiveInt::try_from("0").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = PositiveInt::new(42).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "42"); + let back: PositiveInt = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("0"); + assert!(result.is_err()); + } } diff --git a/src/primitives/probability.rs b/src/primitives/probability.rs index f4b5ad0..fb3a95f 100644 --- a/src/primitives/probability.rs +++ b/src/primitives/probability.rs @@ -1,12 +1,9 @@ use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`Probability`]. pub type ProbabilityInput = f64; -/// Output type for [`Probability`]. -pub type ProbabilityOutput = f64; - /// A probability value in the range `0.0..=1.0`. /// /// NaN, infinite values, and values outside `[0.0, 1.0]` are rejected. @@ -25,12 +22,11 @@ pub type ProbabilityOutput = f64; /// ``` #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "f64", into = "f64"))] pub struct Probability(f64); impl ValueObject for Probability { type Input = ProbabilityInput; - type Output = ProbabilityOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -45,12 +41,39 @@ impl ValueObject for Probability { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for Probability { + type Primitive = f64; + fn value(&self) -> &f64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl TryFrom for Probability { + type Error = ValidationError; + fn try_from(v: f64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for f64 { + fn from(v: Probability) -> f64 { + v.0 + } +} +impl TryFrom<&str> for Probability { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("Probability", value))?; + Self::new(parsed) } } @@ -101,4 +124,37 @@ mod tests { fn rejects_infinity() { assert!(Probability::new(f64::INFINITY).is_err()); } + + #[test] + fn try_from_parses_valid() { + let p = Probability::try_from("0.5").unwrap(); + assert_eq!(*p.value(), 0.5); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(Probability::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_out_of_range() { + assert!(Probability::try_from("1.1").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = Probability::new(0.5).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "0.5"); + let back: Probability = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("1.1"); + assert!(result.is_err()); + } } diff --git a/src/temporal/birth_date.rs b/src/temporal/birth_date.rs index 8893cc3..aea08ce 100644 --- a/src/temporal/birth_date.rs +++ b/src/temporal/birth_date.rs @@ -1,14 +1,11 @@ use chrono::{Datelike, Local, NaiveDate}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`BirthDate`]. pub type BirthDateInput = NaiveDate; -/// Output type for [`BirthDate`]. -pub type BirthDateOutput = NaiveDate; - /// A validated date of birth. /// /// The date must be strictly in the past and no more than 150 years before @@ -27,12 +24,14 @@ pub type BirthDateOutput = NaiveDate; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr( + feature = "serde", + serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate") +)] pub struct BirthDate(NaiveDate); impl ValueObject for BirthDate { type Input = BirthDateInput; - type Output = BirthDateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -53,14 +52,16 @@ impl ValueObject for BirthDate { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for BirthDate { + type Primitive = chrono::NaiveDate; + fn value(&self) -> &chrono::NaiveDate { + &self.0 + } +} impl BirthDate { /// Returns the person's age in full completed years as of today. @@ -74,6 +75,34 @@ impl BirthDate { (years - 1) as u32 } } + + /// Returns `true` if the person is under 18 years old as of today. + pub fn is_minor(&self) -> bool { + self.age_years() < 18 + } +} + +impl TryFrom for BirthDate { + type Error = ValidationError; + fn try_from(v: chrono::NaiveDate) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for chrono::NaiveDate { + fn from(v: BirthDate) -> chrono::NaiveDate { + v.0 + } +} +impl TryFrom<&str> for BirthDate { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") + .map_err(|_| ValidationError::invalid("BirthDate", value))?; + Self::new(parsed) + } } impl std::fmt::Display for BirthDate { @@ -136,4 +165,37 @@ mod tests { let d = BirthDate::new(past_date()).unwrap(); assert_eq!(d.into_inner(), past_date()); } + + #[test] + fn try_from_parses_valid() { + let d = BirthDate::try_from("1990-06-15").unwrap(); + assert_eq!(d.value().to_string(), "1990-06-15"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(BirthDate::try_from("15-06-1990").is_err()); + } + + #[test] + fn try_from_rejects_future_date() { + assert!(BirthDate::try_from("2099-01-01").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = BirthDate::try_from("1990-06-15").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "\"1990-06-15\""); + let back: BirthDate = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"2099-01-01\""); + assert!(result.is_err()); + } } diff --git a/src/temporal/business_hours.rs b/src/temporal/business_hours.rs index e2d3451..392d33e 100644 --- a/src/temporal/business_hours.rs +++ b/src/temporal/business_hours.rs @@ -14,9 +14,6 @@ pub struct BusinessHoursInput { pub close: NaiveTime, } -/// Output type for [`BusinessHours`] — canonical `" HH:MM–HH:MM"` string. -pub type BusinessHoursOutput = String; - /// Validated business hours for a single weekday. /// /// `open` must be strictly before `close`. The canonical output is formatted @@ -40,17 +37,16 @@ pub type BusinessHoursOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct BusinessHours { weekday: Weekday, open: NaiveTime, close: NaiveTime, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for BusinessHours { type Input = BusinessHoursInput; - type Output = BusinessHoursOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -83,10 +79,6 @@ impl ValueObject for BusinessHours { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { BusinessHoursInput { weekday: self.weekday, @@ -97,6 +89,10 @@ impl ValueObject for BusinessHours { } impl BusinessHours { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the weekday. pub fn weekday(&self) -> Weekday { self.weekday @@ -116,6 +112,54 @@ impl BusinessHours { pub fn duration(&self) -> Duration { self.close - self.open } + + /// Returns `true` if `time` falls within `[open, close)`. + pub fn is_open_at(&self, time: NaiveTime) -> bool { + time >= self.open && time < self.close + } +} + +impl TryFrom<&str> for BusinessHours { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("BusinessHours", value); + let (day_str, times_str) = value.trim().split_once(' ').ok_or_else(err)?; + let weekday = match day_str { + "Mon" => chrono::Weekday::Mon, + "Tue" => chrono::Weekday::Tue, + "Wed" => chrono::Weekday::Wed, + "Thu" => chrono::Weekday::Thu, + "Fri" => chrono::Weekday::Fri, + "Sat" => chrono::Weekday::Sat, + "Sun" => chrono::Weekday::Sun, + _ => return Err(err()), + }; + let (open_str, close_str) = times_str.split_once('\u{2013}').ok_or_else(err)?; + let open = + chrono::NaiveTime::parse_from_str(open_str.trim(), "%H:%M").map_err(|_| err())?; + let close = + chrono::NaiveTime::parse_from_str(close_str.trim(), "%H:%M").map_err(|_| err())?; + Self::new(BusinessHoursInput { + weekday, + open, + close, + }) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: BusinessHours) -> String { + v.canonical + } +} + +impl TryFrom for BusinessHours { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } } impl std::fmt::Display for BusinessHours { @@ -231,6 +275,32 @@ mod tests { } } + #[test] + fn is_open_at_during_hours() { + let h = BusinessHours::new(valid_input()).unwrap(); + let noon = NaiveTime::from_hms_opt(12, 0, 0).unwrap(); + assert!(h.is_open_at(noon)); + } + + #[test] + fn is_open_at_open_time_inclusive() { + let h = BusinessHours::new(valid_input()).unwrap(); + assert!(h.is_open_at(open())); + } + + #[test] + fn is_open_at_close_time_exclusive() { + let h = BusinessHours::new(valid_input()).unwrap(); + assert!(!h.is_open_at(close())); + } + + #[test] + fn is_open_at_before_open() { + let h = BusinessHours::new(valid_input()).unwrap(); + let early = NaiveTime::from_hms_opt(8, 0, 0).unwrap(); + assert!(!h.is_open_at(early)); + } + #[test] fn display_matches_value() { let h = BusinessHours::new(valid_input()).unwrap(); @@ -243,4 +313,37 @@ mod tests { let h = BusinessHours::new(input.clone()).unwrap(); assert_eq!(h.into_inner(), input); } + + #[test] + fn try_from_parses_valid() { + let h = BusinessHours::try_from("Mon 09:00–17:00").unwrap(); + assert_eq!(h.value(), "Mon 09:00–17:00"); + } + + #[test] + fn try_from_rejects_invalid_day() { + assert!(BusinessHours::try_from("Xyz 09:00–17:00").is_err()); + } + + #[test] + fn try_from_rejects_close_before_open() { + assert!(BusinessHours::try_from("Mon 17:00–09:00").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = BusinessHours::try_from("Mon 09:00–17:00").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: BusinessHours = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = BusinessHours::try_from("Mon 09:00–17:00").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("Mon")); + } } diff --git a/src/temporal/expiry_date.rs b/src/temporal/expiry_date.rs index 764fa60..d65d6d9 100644 --- a/src/temporal/expiry_date.rs +++ b/src/temporal/expiry_date.rs @@ -1,14 +1,11 @@ use chrono::{Local, NaiveDate}; use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`ExpiryDate`]. pub type ExpiryDateInput = NaiveDate; -/// Output type for [`ExpiryDate`]. -pub type ExpiryDateOutput = NaiveDate; - /// A validated expiry date that is strictly in the future. /// /// The date must be after today at construction time. @@ -25,12 +22,14 @@ pub type ExpiryDateOutput = NaiveDate; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr( + feature = "serde", + serde(try_from = "chrono::NaiveDate", into = "chrono::NaiveDate") +)] pub struct ExpiryDate(NaiveDate); impl ValueObject for ExpiryDate { type Input = ExpiryDateInput; - type Output = ExpiryDateOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -43,14 +42,16 @@ impl ValueObject for ExpiryDate { Ok(Self(value)) } - fn value(&self) -> &Self::Output { - &self.0 - } - fn into_inner(self) -> Self::Input { self.0 } } +impl PrimitiveValue for ExpiryDate { + type Primitive = chrono::NaiveDate; + fn value(&self) -> &chrono::NaiveDate { + &self.0 + } +} impl ExpiryDate { /// Returns the number of days from today until the expiry date. @@ -60,6 +61,29 @@ impl ExpiryDate { } } +impl TryFrom for ExpiryDate { + type Error = ValidationError; + fn try_from(v: chrono::NaiveDate) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for chrono::NaiveDate { + fn from(v: ExpiryDate) -> chrono::NaiveDate { + v.0 + } +} +impl TryFrom<&str> for ExpiryDate { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = chrono::NaiveDate::parse_from_str(value.trim(), "%Y-%m-%d") + .map_err(|_| ValidationError::invalid("ExpiryDate", value))?; + Self::new(parsed) + } +} + impl std::fmt::Display for ExpiryDate { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) @@ -114,4 +138,37 @@ mod tests { let d = ExpiryDate::new(future_date()).unwrap(); assert_eq!(d.into_inner(), future_date()); } + + #[test] + fn try_from_parses_valid() { + let d = ExpiryDate::try_from("2030-12-31").unwrap(); + assert_eq!(d.to_string(), "2030-12-31"); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(ExpiryDate::try_from("31-12-2030").is_err()); + } + + #[test] + fn try_from_rejects_past_date() { + assert!(ExpiryDate::try_from("2020-01-01").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = ExpiryDate::try_from("2030-12-31").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "\"2030-12-31\""); + let back: ExpiryDate = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("\"2020-01-01\""); + assert!(result.is_err()); + } } diff --git a/src/temporal/mod.rs b/src/temporal/mod.rs index b3bd6e4..695a388 100644 --- a/src/temporal/mod.rs +++ b/src/temporal/mod.rs @@ -4,8 +4,8 @@ mod expiry_date; mod time_range; mod unix_timestamp; -pub use birth_date::{BirthDate, BirthDateInput, BirthDateOutput}; -pub use business_hours::{BusinessHours, BusinessHoursInput, BusinessHoursOutput}; -pub use expiry_date::{ExpiryDate, ExpiryDateInput, ExpiryDateOutput}; -pub use time_range::{TimeRange, TimeRangeInput, TimeRangeOutput}; -pub use unix_timestamp::{UnixTimestamp, UnixTimestampInput, UnixTimestampOutput}; +pub use birth_date::{BirthDate, BirthDateInput}; +pub use business_hours::{BusinessHours, BusinessHoursInput}; +pub use expiry_date::{ExpiryDate, ExpiryDateInput}; +pub use time_range::{TimeRange, TimeRangeInput}; +pub use unix_timestamp::{UnixTimestamp, UnixTimestampInput}; diff --git a/src/temporal/time_range.rs b/src/temporal/time_range.rs index 7eaaba9..6c7b513 100644 --- a/src/temporal/time_range.rs +++ b/src/temporal/time_range.rs @@ -12,9 +12,6 @@ pub struct TimeRangeInput { pub end: DateTime, } -/// Output type for [`TimeRange`] — canonical `" / "` string. -pub type TimeRangeOutput = String; - /// A validated time range with a start strictly before its end. /// /// Both `start` and `end` are `chrono::DateTime`. The canonical output @@ -37,16 +34,15 @@ pub type TimeRangeOutput = String; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(try_from = "String", into = "String"))] pub struct TimeRange { start: DateTime, end: DateTime, - #[cfg_attr(feature = "serde", serde(skip))] canonical: String, } impl ValueObject for TimeRange { type Input = TimeRangeInput; - type Output = TimeRangeOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -65,10 +61,6 @@ impl ValueObject for TimeRange { }) } - fn value(&self) -> &Self::Output { - &self.canonical - } - fn into_inner(self) -> Self::Input { TimeRangeInput { start: self.start, @@ -78,6 +70,10 @@ impl ValueObject for TimeRange { } impl TimeRange { + pub fn value(&self) -> &str { + &self.canonical + } + /// Returns the start of the range. pub fn start(&self) -> &DateTime { &self.start @@ -92,6 +88,42 @@ impl TimeRange { pub fn duration(&self) -> Duration { self.end - self.start } + + /// Returns `true` if `dt` falls within `[start, end)`. + pub fn contains(&self, dt: &DateTime) -> bool { + dt >= &self.start && dt < &self.end + } + + /// Returns `true` if this range overlaps with `other` (shares at least one instant). + pub fn overlaps(&self, other: &TimeRange) -> bool { + self.start < other.end && other.start < self.end + } +} + +impl TryFrom<&str> for TimeRange { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let err = || ValidationError::invalid("TimeRange", value); + let (start_str, end_str) = value.trim().split_once(" / ").ok_or_else(err)?; + let start: chrono::DateTime = start_str.trim().parse().map_err(|_| err())?; + let end: chrono::DateTime = end_str.trim().parse().map_err(|_| err())?; + Self::new(TimeRangeInput { start, end }) + } +} + +#[cfg(feature = "serde")] +impl From for String { + fn from(v: TimeRange) -> String { + v.canonical + } +} + +impl TryFrom for TimeRange { + type Error = ValidationError; + fn try_from(s: String) -> Result { + Self::try_from(s.as_str()) + } } impl std::fmt::Display for TimeRange { @@ -186,6 +218,81 @@ mod tests { assert_eq!(r.to_string(), r.value().to_owned()); } + #[test] + fn contains_inside() { + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); + let mid = Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap(); + assert!(r.contains(&mid)); + } + + #[test] + fn contains_at_start_inclusive() { + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); + assert!(r.contains(&start())); + } + + #[test] + fn contains_at_end_exclusive() { + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); + assert!(!r.contains(&end())); + } + + #[test] + fn contains_outside() { + let r = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); + let before = Utc.with_ymd_and_hms(2025, 1, 1, 9, 0, 0).unwrap(); + assert!(!r.contains(&before)); + } + + #[test] + fn overlaps_true() { + let r1 = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); + let overlap_start = Utc.with_ymd_and_hms(2025, 1, 1, 11, 0, 0).unwrap(); + let overlap_end = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); + let r2 = TimeRange::new(TimeRangeInput { + start: overlap_start, + end: overlap_end, + }) + .unwrap(); + assert!(r1.overlaps(&r2)); + } + + #[test] + fn overlaps_adjacent_no_overlap() { + let r1 = TimeRange::new(TimeRangeInput { + start: start(), + end: end(), + }) + .unwrap(); + let after_end = Utc.with_ymd_and_hms(2025, 1, 1, 13, 0, 0).unwrap(); + let r2 = TimeRange::new(TimeRangeInput { + start: end(), + end: after_end, + }) + .unwrap(); + assert!(!r1.overlaps(&r2)); + } + #[test] fn into_inner_roundtrip() { let input = TimeRangeInput { @@ -195,4 +302,37 @@ mod tests { let r = TimeRange::new(input.clone()).unwrap(); assert_eq!(r.into_inner(), input); } + + #[test] + fn try_from_parses_valid() { + let r = TimeRange::try_from("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC").unwrap(); + assert_eq!(r.duration().num_hours(), 2); + } + + #[test] + fn try_from_rejects_no_separator() { + assert!(TimeRange::try_from("2025-01-01T10:00:00Z").is_err()); + } + + #[test] + fn try_from_rejects_end_before_start() { + assert!(TimeRange::try_from("2025-01-01 12:00:00 UTC / 2025-01-01 10:00:00 UTC").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = TimeRange::try_from("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + let back: TimeRange = serde_json::from_str(&json).unwrap(); + assert_eq!(v.value(), back.value()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_serializes_as_canonical_string() { + let v = TimeRange::try_from("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC").unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert!(json.contains("2025-01-01 10:00:00 UTC / 2025-01-01 12:00:00 UTC")); + } } diff --git a/src/temporal/unix_timestamp.rs b/src/temporal/unix_timestamp.rs index 939c96d..81f7392 100644 --- a/src/temporal/unix_timestamp.rs +++ b/src/temporal/unix_timestamp.rs @@ -1,12 +1,11 @@ +use chrono::{DateTime, TimeZone, Utc}; + use crate::errors::ValidationError; -use crate::traits::ValueObject; +use crate::traits::{PrimitiveValue, ValueObject}; /// Input type for [`UnixTimestamp`]. pub type UnixTimestampInput = i64; -/// Output type for [`UnixTimestamp`]. -pub type UnixTimestampOutput = i64; - /// A validated Unix timestamp — non-negative seconds since the Unix epoch. /// /// Negative values (pre-1970) are rejected. @@ -24,12 +23,11 @@ pub type UnixTimestampOutput = i64; /// ``` #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(transparent))] +#[cfg_attr(feature = "serde", serde(try_from = "i64", into = "i64"))] pub struct UnixTimestamp(i64); impl ValueObject for UnixTimestamp { type Input = UnixTimestampInput; - type Output = UnixTimestampOutput; type Error = ValidationError; fn new(value: Self::Input) -> Result { @@ -42,12 +40,48 @@ impl ValueObject for UnixTimestamp { Ok(Self(value)) } - fn value(&self) -> &Self::Output { + fn into_inner(self) -> Self::Input { + self.0 + } +} +impl PrimitiveValue for UnixTimestamp { + type Primitive = i64; + fn value(&self) -> &i64 { &self.0 } +} - fn into_inner(self) -> Self::Input { - self.0 +impl UnixTimestamp { + /// Converts to a `DateTime`. + pub fn as_datetime(&self) -> DateTime { + Utc.timestamp_opt(self.0, 0) + .single() + .expect("valid timestamp") + } +} + +impl TryFrom for UnixTimestamp { + type Error = ValidationError; + fn try_from(v: i64) -> Result { + Self::new(v) + } +} + +#[cfg(feature = "serde")] +impl From for i64 { + fn from(v: UnixTimestamp) -> i64 { + v.0 + } +} +impl TryFrom<&str> for UnixTimestamp { + type Error = ValidationError; + + fn try_from(value: &str) -> Result { + let parsed = value + .trim() + .parse::() + .map_err(|_| ValidationError::invalid("UnixTimestamp", value))?; + Self::new(parsed) } } @@ -84,9 +118,54 @@ mod tests { assert_eq!(ts.into_inner(), 42); } + #[test] + fn as_datetime_epoch() { + let ts = UnixTimestamp::new(0).unwrap(); + assert_eq!(ts.as_datetime().timestamp(), 0); + } + + #[test] + fn as_datetime_nonzero() { + let ts = UnixTimestamp::new(1_700_000_000).unwrap(); + assert_eq!(ts.as_datetime().timestamp(), 1_700_000_000); + } + #[test] fn display() { let ts = UnixTimestamp::new(1_000).unwrap(); assert_eq!(ts.to_string(), "1000"); } + + #[test] + fn try_from_parses_valid() { + let ts = UnixTimestamp::try_from("1700000000").unwrap(); + assert_eq!(*ts.value(), 1_700_000_000); + } + + #[test] + fn try_from_rejects_invalid_format() { + assert!(UnixTimestamp::try_from("abc").is_err()); + } + + #[test] + fn try_from_rejects_negative() { + assert!(UnixTimestamp::try_from("-1").is_err()); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_roundtrip() { + let v = UnixTimestamp::new(1_700_000_000).unwrap(); + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, "1700000000"); + let back: UnixTimestamp = serde_json::from_str(&json).unwrap(); + assert_eq!(v, back); + } + + #[cfg(feature = "serde")] + #[test] + fn serde_deserialize_validates() { + let result: Result = serde_json::from_str("-1"); + assert!(result.is_err()); + } } diff --git a/src/traits.rs b/src/traits.rs index 41dfca3..1cf7a18 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -1,100 +1,88 @@ /// Core trait for all value objects in arvo. /// /// A value object is an immutable, validated wrapper around a raw value. -/// It guarantees that once constructed, the inner value always satisfies -/// the domain rules defined in [`ValueObject::new`]. +/// Construction via [`new`](ValueObject::new) is the **only** way to obtain +/// a valid instance — invalid states are unrepresentable at the type level. /// /// # Type parameters /// /// - `Input` — the type accepted by [`new`](ValueObject::new). /// For simple types this is the raw primitive (e.g. `String`). /// For composite types this is a dedicated input struct. -/// - `Output` — the type returned by [`value`](ValueObject::value). -/// For simple types `Input` and `Output` are the same. -/// For composite types `Output` is the canonical representation -/// (e.g. an E.164 string for a phone number). /// - `Error` — the error returned when validation fails. /// -/// # Simple type example +/// Simple types (single-primitive wrappers) additionally implement +/// [`PrimitiveValue`], which exposes the inner value via [`value()`](PrimitiveValue::value). +/// Composite types expose their data through dedicated accessor methods instead. +/// +/// # Example /// /// ```rust,ignore -/// use arvo::traits::ValueObject; +/// use arvo::traits::{ValueObject, PrimitiveValue}; /// use arvo::errors::ValidationError; /// -/// pub type PercentageInput = f64; -/// pub type PercentageOutput = f64; -/// -/// pub struct Percentage(f64); +/// pub struct NonNegative(f64); /// -/// impl ValueObject for Percentage { -/// type Input = PercentageInput; -/// type Output = PercentageOutput; -/// type Error = ValidationError; +/// impl ValueObject for NonNegative { +/// type Input = f64; +/// type Error = ValidationError; /// /// fn new(value: f64) -> Result { -/// if !(0.0..=100.0).contains(&value) { -/// return Err(ValidationError::OutOfRange { -/// type_name: "Percentage", -/// min: "0".into(), -/// max: "100".into(), -/// actual: value.to_string(), -/// }); +/// if value < 0.0 { +/// return Err(ValidationError::invalid("NonNegative", &value.to_string())); /// } /// Ok(Self(value)) /// } /// -/// fn value(&self) -> &f64 { &self.0 } /// fn into_inner(self) -> f64 { self.0 } /// } -/// ``` -/// -/// # Composite type example -/// -/// ```rust,ignore -/// use arvo::traits::ValueObject; -/// use arvo::errors::ValidationError; /// -/// pub struct PhoneNumberInput { -/// pub country_code: CountryCode, -/// pub number: String, -/// } -/// pub type PhoneNumberOutput = String; // canonical E.164: "+420123456789" -/// -/// pub struct PhoneNumber { -/// input: PhoneNumberInput, -/// e164: String, -/// } -/// -/// impl ValueObject for PhoneNumber { -/// type Input = PhoneNumberInput; -/// type Output = PhoneNumberOutput; -/// type Error = ValidationError; -/// -/// fn new(value: PhoneNumberInput) -> Result { /* ... */ } -/// fn value(&self) -> &String { &self.e164 } // "+420123456789" -/// fn into_inner(self) -> PhoneNumberInput { self.input } +/// impl PrimitiveValue for NonNegative { +/// type Primitive = f64; +/// fn value(&self) -> &f64 { &self.0 } /// } /// ``` pub trait ValueObject: Sized + Clone + PartialEq { /// The type accepted by [`new`](ValueObject::new). type Input; - /// The type returned by [`value`](ValueObject::value). - type Output: ?Sized; - /// The error produced when validation fails. type Error: std::error::Error; - /// Constructs a new value object, validating the input. + /// Constructs a new value object, validating and normalising the input. /// /// Returns `Err` if the value does not satisfy domain constraints. - /// This is the **only** way to create a valid instance — there is - /// no public struct constructor. fn new(value: Self::Input) -> Result; - /// Returns a reference to the validated output value. - fn value(&self) -> &Self::Output; - /// Consumes the value object and returns the original input value. fn into_inner(self) -> Self::Input; } + +/// Extension of [`ValueObject`] for simple single-primitive newtypes. +/// +/// Implemented by every type whose validated representation is a single +/// primitive value (e.g. `EmailAddress` wraps `String`, `Latitude` wraps `f64`). +/// Composite types (e.g. `Money`, `PostalAddress`) do **not** implement this +/// trait — they expose their data through dedicated accessor methods. +/// +/// # Example +/// +/// ```rust,ignore +/// use arvo::contact::EmailAddress; +/// use arvo::traits::{ValueObject, PrimitiveValue}; +/// +/// let email = EmailAddress::new("user@example.com".into())?; +/// assert_eq!(email.value(), "user@example.com"); +/// +/// // Generic bound for code that only needs the inner primitive: +/// fn print_value>(v: &T) { +/// println!("{}", v.value()); +/// } +/// ``` +pub trait PrimitiveValue: ValueObject { + /// The primitive type wrapped by this value object. + type Primitive: ?Sized; + + /// Returns a reference to the validated inner value. + fn value(&self) -> &Self::Primitive; +}