diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1097882..b97f7ff 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ permissions: contents: read env: - RUST_TOOLCHAIN: '1.77.1' + RUST_TOOLCHAIN: '1.94.0' RUST_TOOLCHAIN_RUSTFMT: nightly RUST_TOOLCHAIN_RUSTDOC: nightly CARGO_TERM_COLOR: always diff --git a/Cargo.lock b/Cargo.lock index fef3a13..31b6602 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,41 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytecheck" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cfg-if" @@ -14,6 +49,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "getrandom" version = "0.2.12" @@ -25,30 +66,83 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" +[[package]] +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "nid" version = "3.0.0" dependencies = [ "pretty_assertions", "rand", + "rkyv", "serde", "serde_json", "thiserror", "zeroize", ] +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -67,13 +161,33 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.79" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.35" @@ -83,6 +197,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" version = "0.8.5" @@ -113,6 +236,51 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "rkyv" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a30e631b7f4a03dee9056b8ef6982e8ba371dd5bedb74d3ec86df4499132c70" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown", + "indexmap", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8100bb34c0a1d0f907143db3149e6b4eea3c33b9ee8b189720168e818303986f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.17" @@ -150,11 +318,17 @@ dependencies = [ "serde", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "syn" -version = "2.0.57" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11a6ae1e52eb25aab8f3fb9fca13be982a373b8f1157ca14b897a825ba4a2d35" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -181,18 +355,88 @@ dependencies = [ "syn", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + [[package]] name = "yansi" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index d4eb3a5..7f37073 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ name = "nid" version = "3.0.0" edition = "2021" -rust-version = "1.61" description = "Generate and parse Nano IDs" repository = "https://github.com/ciffelia/nid" license = "MIT OR Apache-2.0" @@ -19,7 +18,15 @@ thiserror = "1.0" serde = { version = "1.0", optional = true } rand = "0.8.5" zeroize = { version = "1.7", features = ["zeroize_derive"], optional = true } +rkyv = { version = "0.8", optional = true } [dev-dependencies] serde_json = "1.0" pretty_assertions = "1.4" + +[features] +packed = [] +rkyv = ["dep:rkyv"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_auto_cfg)'] } diff --git a/src/lib.rs b/src/lib.rs index d503724..0361530 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,7 @@ //! //! - `serde`: Add support for serialization and deserialization of [`Nanoid`]. Implement [`serde::Serialize`] and [`serde::Deserialize`] for [`Nanoid`]. //! - `zeroize`: Add support for zeroizing the memory of [`Nanoid`]. Implement [`zeroize::Zeroize`] for [`Nanoid`]. +//! - `packed`: Add support for packed byte representation of Nano IDs. See [`packed`] module. //! //! # Comparison with other implementations of Nano ID //! @@ -89,10 +90,15 @@ //! This type provides a safe way to generate and parse Nano IDs. //! This is similar to [`uuid`](https://docs.rs/uuid) crate, which provides [`Uuid`](https://docs.rs/uuid/latest/uuid/struct.Uuid.html) type to represent UUIDs. -#![cfg_attr(doc_auto_cfg, feature(doc_auto_cfg))] +#![cfg_attr(doc_auto_cfg, feature(doc_cfg))] #![deny(missing_debug_implementations, missing_docs)] pub mod alphabet; +#[cfg(feature = "packed")] +pub mod packed; + +#[cfg(feature = "packed")] +pub use packed::PackedNanoid; use std::{marker::PhantomData, mem::MaybeUninit}; @@ -339,6 +345,38 @@ impl Nanoid { } } +#[cfg(feature = "rkyv")] +impl rkyv::Archive for Nanoid { + type Archived = [u8; N]; + type Resolver = [(); N]; + + fn resolve(&self, _: Self::Resolver, out: rkyv::Place) { + out.write(self.inner); + } +} + +#[cfg(feature = "rkyv")] +impl rkyv::Serialize for Nanoid +where + S: rkyv::rancor::Fallible + ?Sized, +{ + fn serialize(&self, serializer: &mut S) -> Result { + self.inner.serialize(serializer) + } +} + +#[cfg(feature = "rkyv")] +impl + rkyv::Deserialize, D> for [u8; N] +{ + fn deserialize(&self, _: &mut D) -> Result, D::Error> { + Ok(Nanoid { + inner: *self, + _marker: PhantomData, + }) + } +} + // `Copy` cannot be derived due to a limitation of the compiler. // https://github.com/rust-lang/rust/issues/26925 impl Copy for Nanoid {} diff --git a/src/packed.rs b/src/packed.rs new file mode 100644 index 0000000..9e30c15 --- /dev/null +++ b/src/packed.rs @@ -0,0 +1,679 @@ +//! Packed byte representation for [`Nanoid`]. +//! +//! This module provides a compact storage format for [`Nanoid`]s where multiple +//! characters are packed into fewer bytes based on the [`Alphabet`] size. +//! +//! # Example +//! +//! ``` +//! use nid::{Nanoid, alphabet::Base64UrlAlphabet, packed::PackedNanoid}; +//! +//! let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); +//! let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id)?; +//! let unpacked: Nanoid<21, Base64UrlAlphabet> = packed.unpack()?; +//! assert_eq!(id, unpacked); +//! # Ok::<(), nid::packed::PackError>(()) +//! ``` + +use std::marker::PhantomData; + +use crate::alphabet::{Alphabet, AlphabetExt}; +use crate::Nanoid; + +/// An error that can occur during pack/unpack operations. +#[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)] +pub enum PackError { + /// The character is not in the [`Alphabet`] (during pack). + #[error("Invalid character '{char}' at position {position}")] + InvalidCharacter { + /// The position in the [`Nanoid`]. + position: usize, + /// The invalid character. + char: char, + }, + + /// The character index is out of range for the [`Alphabet`] (during unpack). + #[error("Invalid character index {index} at position {position}")] + InvalidIndex { + /// The position in the [`Nanoid`]. + position: usize, + /// The invalid index value. + index: usize, + }, +} + +/// An extension trait for [`Alphabet`] that provides the pack size and reverse lookup. +/// +/// This trait defines how many bits are needed to represent each character +/// in the packed representation, and provides a reverse lookup map for +/// O(1) character-to-index conversion. +pub trait AlphabetPackExt: Alphabet { + /// Number of bits per character in the packed representation. + const PACK_BITS: usize; + + /// Reverse lookup map: maps ASCII character to its index in the [`Alphabet`]. + /// Value is 255 ([`u8::MAX`]) for characters not in the [`Alphabet`]. + const CHAR_TO_INDEX: [u8; 128]; + + /// Get the index of a character in the [`Alphabet`]. + /// Returns `None` if the character is not in the alphabet or not ASCII. + #[inline] + fn char_to_index(ch: u8) -> Option { + if ch >= 128 { + return None; + } + let idx = Self::CHAR_TO_INDEX[ch as usize]; + if idx == u8::MAX { + None + } else { + Some(idx as usize) + } + } +} + +/// Blanket implementation of [`AlphabetPackExt`] for all [`Alphabet`] types. +/// +/// This automatically computes: +/// - `PACK_BITS` from the alphabet size. +/// - `CHAR_TO_INDEX` from the symbol list. +impl AlphabetPackExt for A { + const PACK_BITS: usize = (::SYMBOL_LIST.len() - 1).ilog2() as usize + 1; + + const CHAR_TO_INDEX: [u8; 128] = { + let mut map = [u8::MAX; 128]; + let mut i = 0; + while i < ::SYMBOL_LIST.len() { + map[::SYMBOL_LIST[i] as usize] = i as u8; + i += 1; + } + map + }; +} + +/// A packed byte representation of a [`Nanoid`]. +/// +/// This struct stores [`Nanoid`]s more efficiently by packing multiple characters +/// into each byte based on the [`Alphabet`]'s pack size. +/// +/// # Type Parameters +/// +/// - `N`: Number of characters (same as the corresponding [`Nanoid`]) +/// - `B`: Number of packed bytes (`ceil(N * A::PACK_BITS / 8)`) +/// - `A`: Alphabet type that implements [`AlphabetPackExt`] +/// +/// # Example +/// +/// ``` +/// use nid::{Nanoid, alphabet::Base64UrlAlphabet, packed::PackedNanoid}; +/// +/// // A 21-character Base64Url Nano ID packs into 16 bytes +/// let id: Nanoid<21, Base64UrlAlphabet> = "qjH-6uGrFy0QgNJtUh0_c".parse()?; +/// let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id)?; +/// +/// // Get the raw packed bytes +/// let bytes: &[u8; 16] = packed.as_bytes(); +/// +/// // Unpack back to the original Nano ID +/// let unpacked: Nanoid<21, Base64UrlAlphabet> = packed.unpack()?; +/// assert_eq!(id, unpacked); +/// # Ok::<(), Box>(()) +/// ``` +#[cfg_attr(feature = "zeroize", derive(zeroize::Zeroize))] +pub struct PackedNanoid { + inner: [u8; B], + _marker: PhantomData A>, +} + +impl PackedNanoid { + /// Pack a [`Nanoid`] into a [`PackedNanoid`]. + /// + /// # Errors + /// + /// Returns [`PackError::InvalidIndex`] if a character cannot be found in the [`Alphabet`]. + /// + /// # Example + /// + /// ``` + /// use nid::{Nanoid, alphabet::Base64UrlAlphabet, packed::PackedNanoid}; + /// + /// let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + /// let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id)?; + /// # Ok::<(), nid::packed::PackError>(()) + /// ``` + pub fn pack(nanoid: &Nanoid) -> Result { + let mut packed = [0u8; B]; + Self::pack_impl(&nanoid.inner, &mut packed)?; + Ok(Self { + inner: packed, + _marker: PhantomData, + }) + } + + /// Unpack a [`PackedNanoid`] back to a [`Nanoid`]. + /// + /// # Errors + /// + /// Returns [`PackError::InvalidIndex`] if the packed data contains an invalid + /// character for the [`Alphabet`]. + /// + /// # Example + /// + /// ``` + /// use nid::{Nanoid, alphabet::Base64UrlAlphabet, packed::PackedNanoid}; + /// + /// let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + /// let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id)?; + /// let unpacked: Nanoid<21, Base64UrlAlphabet> = packed.unpack()?; + /// assert_eq!(id, unpacked); + /// # Ok::<(), nid::packed::PackError>(()) + /// ``` + pub fn unpack(&self) -> Result, PackError> { + let mut chars = [0u8; N]; + Self::unpack_impl(&self.inner, &mut chars)?; + + // SAFETY: unpack_impl validates all characters + Ok(Nanoid { + inner: chars, + _marker: PhantomData, + }) + } + + /// Get the packed bytes. + /// + /// # Example + /// + /// ``` + /// use nid::{Nanoid, alphabet::Base64UrlAlphabet, packed::PackedNanoid}; + /// + /// let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + /// let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id)?; + /// let bytes: &[u8; 16] = packed.as_bytes(); + /// # Ok::<(), nid::packed::PackError>(()) + /// ``` + #[must_use] + #[inline] + pub const fn as_bytes(&self) -> &[u8; B] { + &self.inner + } + + /// Create a [`PackedNanoid`] from raw packed bytes without validation. + /// + /// # Safety + /// + /// The caller must ensure the bytes represent a valid packed [`Nanoid`]. + #[must_use] + #[inline] + pub const unsafe fn from_bytes_unchecked(bytes: [u8; B]) -> Self { + Self { + inner: bytes, + _marker: PhantomData, + } + } + + fn pack_impl(src: &[u8; N], dst: &mut [u8; B]) -> Result<(), PackError> { + let pack_bits = A::PACK_BITS; + let mut bit_buffer: u64 = 0; + let mut bits_in_buffer: usize = 0; + let mut dst_idx: usize = 0; + + for (i, &ch) in src.iter().enumerate() { + let idx = A::char_to_index(ch).ok_or(PackError::InvalidCharacter { + position: i, + char: ch as char, + })?; + + bit_buffer = (bit_buffer << pack_bits) | (idx as u64); + bits_in_buffer += pack_bits; + + while bits_in_buffer >= 8 && dst_idx < B { + bits_in_buffer -= 8; + dst[dst_idx] = ((bit_buffer >> bits_in_buffer) & 0xFF) as u8; + dst_idx += 1; + } + } + + if bits_in_buffer > 0 && dst_idx < B { + dst[dst_idx] = ((bit_buffer << (8 - bits_in_buffer)) & 0xFF) as u8; + } + + Ok(()) + } + + fn unpack_impl(src: &[u8; B], dst: &mut [u8; N]) -> Result<(), PackError> { + let pack_bits = A::PACK_BITS; + let mask = (1u64 << pack_bits) - 1; + let mut bit_buffer: u64 = 0; + let mut bits_in_buffer: usize = 0; + let mut src_idx: usize = 0; + + for (i, dst_byte) in dst.iter_mut().enumerate() { + while bits_in_buffer < pack_bits && src_idx < B { + bit_buffer = (bit_buffer << 8) | (src[src_idx] as u64); + bits_in_buffer += 8; + src_idx += 1; + } + + bits_in_buffer -= pack_bits; + let idx = ((bit_buffer >> bits_in_buffer) & mask) as usize; + + if idx >= A::VALID_SYMBOL_LIST.len() { + return Err(PackError::InvalidIndex { + position: i, + index: idx, + }); + } + + *dst_byte = A::VALID_SYMBOL_LIST[idx]; + } + + Ok(()) + } +} + +#[cfg(feature = "rkyv")] +impl rkyv::Archive for PackedNanoid { + type Archived = [u8; B]; + type Resolver = [(); B]; + + fn resolve(&self, _: Self::Resolver, out: rkyv::Place) { + out.write(self.inner); + } +} + +#[cfg(feature = "rkyv")] +impl rkyv::Serialize for PackedNanoid +where + S: rkyv::rancor::Fallible + ?Sized, +{ + fn serialize(&self, serializer: &mut S) -> Result { + self.inner.serialize(serializer) + } +} + +#[cfg(feature = "rkyv")] +impl + rkyv::Deserialize, D> for [u8; B] +{ + fn deserialize(&self, _: &mut D) -> Result, D::Error> { + Ok(PackedNanoid { + inner: *self, + _marker: PhantomData, + }) + } +} + +impl Default for PackedNanoid { + fn default() -> Self { + Self { + inner: [0u8; B], + _marker: PhantomData, + } + } +} + +impl Copy for PackedNanoid {} + +impl Clone for PackedNanoid { + fn clone(&self) -> Self { + *self + } +} + +impl PartialEq for PackedNanoid { + fn eq(&self, other: &Self) -> bool { + self.inner == other.inner + } +} + +impl Eq for PackedNanoid {} + +impl std::hash::Hash for PackedNanoid { + fn hash(&self, state: &mut H) { + self.inner.hash(state); + } +} + +impl PartialOrd for PackedNanoid { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PackedNanoid { + #[inline] + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.inner.cmp(&other.inner) + } +} + +impl std::fmt::Debug for PackedNanoid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("PackedNanoid").field(&self.inner).finish() + } +} + +impl AsRef<[u8; B]> for PackedNanoid { + fn as_ref(&self) -> &[u8; B] { + &self.inner + } +} + +// TODO: Remove this when #76560 issue (generic_const_exprs) will be stabilized. +/// Create a [`PackedNanoid`] type with automatic byte size computation. +/// +/// This macro computes the required byte size `B` based on the number of +/// characters `N` and the alphabet's `PACK_BITS` using the formula: +/// `ceil(N * PACK_BITS / 8)`. +/// +/// # Example +/// +/// ``` +/// use nid::{alphabet::Base64UrlAlphabet, packed_nanoid_type}; +/// +/// // Creates type PackedNanoid<21, Base64UrlAlphabet, 16> +/// // (21 * 6 bits = 126 bits = 16 bytes) +/// type PackedId = packed_nanoid_type!(21, Base64UrlAlphabet); +/// ``` +#[macro_export] +macro_rules! packed_nanoid_type { + ($n:expr, $alphabet:ty) => { + $crate::PackedNanoid< + $n, + { + ($n * <$alphabet as $crate::packed::AlphabetPackExt>::PACK_BITS).div_ceil(8) + }, + $alphabet, + > + }; +} + +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_ne}; + + use super::*; + use crate::alphabet::{ + Base16Alphabet, Base32Alphabet, Base36Alphabet, Base58Alphabet, Base62Alphabet, + Base64UrlAlphabet, + }; + + #[test] + fn test_pack_bits_values() { + assert_eq!(Base16Alphabet::PACK_BITS, 4); + assert_eq!(Base32Alphabet::PACK_BITS, 5); + assert_eq!(Base36Alphabet::PACK_BITS, 6); + assert_eq!(Base58Alphabet::PACK_BITS, 6); + assert_eq!(Base62Alphabet::PACK_BITS, 6); + assert_eq!(Base64UrlAlphabet::PACK_BITS, 6); + } + + #[test] + fn test_roundtrip_base64url() { + for _ in 0..100 { + let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<10, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<10, 8, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<8, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<8, 6, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<4, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<4, 3, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + } + + #[test] + fn test_roundtrip_base32() { + for _ in 0..100 { + let id: Nanoid<21, Base32Alphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 14, Base32Alphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<10, Base32Alphabet> = Nanoid::new(); + let packed: PackedNanoid<10, 7, Base32Alphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<8, Base32Alphabet> = Nanoid::new(); + let packed: PackedNanoid<8, 5, Base32Alphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + } + + #[test] + fn test_roundtrip_base16() { + for _ in 0..100 { + let id: Nanoid<21, Base16Alphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 11, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<10, Base16Alphabet> = Nanoid::new(); + let packed: PackedNanoid<10, 5, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + for _ in 0..100 { + let id: Nanoid<8, Base16Alphabet> = Nanoid::new(); + let packed: PackedNanoid<8, 4, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + let unpacked = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + } + + #[test] + fn test_packed_size_reduction() { + let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + assert_eq!(packed.as_bytes().len(), 16); + + let id: Nanoid<21, Base32Alphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 14, Base32Alphabet> = PackedNanoid::pack(&id).unwrap(); + assert_eq!(packed.as_bytes().len(), 14); + + let id: Nanoid<21, Base16Alphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 11, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + assert_eq!(packed.as_bytes().len(), 11); + } + + #[test] + fn test_eq() { + let id: Nanoid<21, Base64UrlAlphabet> = "ABCDEFGHIJKLMNOPQ123_".parse().unwrap(); + let packed1: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + let packed2: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + assert_eq!(packed1, packed2); + } + + #[test] + fn test_ne() { + let id1: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let id2: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed1: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id1).unwrap(); + let packed2: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id2).unwrap(); + assert_ne!(packed1, packed2); + } + + #[test] + fn test_packed_nanoid_type_macro() { + type Packed64 = packed_nanoid_type!(21, Base64UrlAlphabet); + let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed: Packed64 = PackedNanoid::pack(&id).unwrap(); + let unpacked: Nanoid<21, Base64UrlAlphabet> = packed.unpack().unwrap(); + assert_eq!(id, unpacked); + } + + #[test] + fn test_known_values_base16() { + // Base16Alphabet: "ABCDEF0123456789" + // '0' = index 6 = 0b0110 + // '1' = index 7 = 0b0111 + // '2' = index 8 = 0b1000 + // '3' = index 9 = 0b1001 + // Packed MSB-first: 01100111 10001001 = 0x67, 0x89 + let id: Nanoid<4, Base16Alphabet> = "0123".parse().unwrap(); + let packed: PackedNanoid<4, 2, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + assert_eq!(packed.as_bytes(), &[0x67, 0x89]); + + let unpacked = packed.unpack().unwrap(); + assert_eq!(unpacked.as_str(), "0123"); + } + + #[test] + fn test_char_to_index() { + // Test Base64UrlAlphabet + assert_eq!(Base64UrlAlphabet::char_to_index(b'A'), Some(0)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'Z'), Some(25)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'a'), Some(26)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'z'), Some(51)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'0'), Some(52)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'9'), Some(61)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'_'), Some(62)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'-'), Some(63)); + assert_eq!(Base64UrlAlphabet::char_to_index(b'@'), None); + assert_eq!(Base64UrlAlphabet::char_to_index(b' '), None); + assert_eq!(Base64UrlAlphabet::char_to_index(0x80), None); // non-ASCII + + // Test Base16Alphabet + assert_eq!(Base16Alphabet::char_to_index(b'A'), Some(0)); + assert_eq!(Base16Alphabet::char_to_index(b'F'), Some(5)); + assert_eq!(Base16Alphabet::char_to_index(b'0'), Some(6)); + assert_eq!(Base16Alphabet::char_to_index(b'9'), Some(15)); + assert_eq!(Base16Alphabet::char_to_index(b'G'), None); + assert_eq!(Base16Alphabet::char_to_index(b'a'), None); + + // Test Base32Alphabet + assert_eq!(Base32Alphabet::char_to_index(b'A'), Some(0)); + assert_eq!(Base32Alphabet::char_to_index(b'Z'), Some(25)); + assert_eq!(Base32Alphabet::char_to_index(b'2'), Some(26)); + assert_eq!(Base32Alphabet::char_to_index(b'7'), Some(31)); + assert_eq!(Base32Alphabet::char_to_index(b'1'), None); + assert_eq!(Base32Alphabet::char_to_index(b'8'), None); + } + + #[cfg(feature = "rkyv")] + #[cfg(test)] + mod rkyv_tests { + use rkyv::rancor::Error; + + use crate::alphabet::{Base16Alphabet, Base32Alphabet, Base64UrlAlphabet}; + use crate::packed::PackedNanoid; + use crate::Nanoid; + + #[test] + fn test_rkyv_archive_bytes_match_packed() { + let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + + let archived = rkyv::to_bytes::(&packed).unwrap(); + let archived_ref: &[u8; 16] = unsafe { &*archived.as_ptr().cast() }; + + assert_eq!(archived_ref, packed.as_bytes()); + } + + #[test] + fn test_rkyv_roundtrip_base64url() { + let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + + let bytes = rkyv::to_bytes::(&packed).unwrap(); + let deserialized: PackedNanoid<21, 16, Base64UrlAlphabet> = + rkyv::from_bytes::, Error>(&bytes).unwrap(); + + assert_eq!(packed, deserialized); + assert_eq!(id, deserialized.unpack().unwrap()); + } + + #[test] + fn test_rkyv_roundtrip_base32() { + let id: Nanoid<21, Base32Alphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 14, Base32Alphabet> = PackedNanoid::pack(&id).unwrap(); + + let bytes = rkyv::to_bytes::(&packed).unwrap(); + let deserialized: PackedNanoid<21, 14, Base32Alphabet> = + rkyv::from_bytes::, Error>(&bytes).unwrap(); + + assert_eq!(packed, deserialized); + assert_eq!(id, deserialized.unpack().unwrap()); + } + + #[test] + fn test_rkyv_roundtrip_base16() { + let id: Nanoid<21, Base16Alphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 11, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + + let bytes = rkyv::to_bytes::(&packed).unwrap(); + let deserialized: PackedNanoid<21, 11, Base16Alphabet> = + rkyv::from_bytes::, Error>(&bytes).unwrap(); + + assert_eq!(packed, deserialized); + assert_eq!(id, deserialized.unpack().unwrap()); + } + + #[test] + fn test_rkyv_roundtrip_different_sizes() { + for _ in 0..10 { + let id: Nanoid<10, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<10, 8, Base64UrlAlphabet> = + PackedNanoid::pack(&id).unwrap(); + + let bytes = rkyv::to_bytes::(&packed).unwrap(); + let deserialized: PackedNanoid<10, 8, Base64UrlAlphabet> = + rkyv::from_bytes::, Error>(&bytes) + .unwrap(); + + assert_eq!(packed, deserialized); + assert_eq!(id, deserialized.unpack().unwrap()); + } + } + + #[test] + fn test_rkyv_archive_size_equals_packed_size() { + let id: Nanoid<21, Base64UrlAlphabet> = Nanoid::new(); + let packed: PackedNanoid<21, 16, Base64UrlAlphabet> = PackedNanoid::pack(&id).unwrap(); + + let bytes = rkyv::to_bytes::(&packed).unwrap(); + assert_eq!(bytes.len(), 16); + } + + #[test] + fn test_rkyv_known_value() { + let id: Nanoid<4, Base16Alphabet> = "0123".parse().unwrap(); + let packed: PackedNanoid<4, 2, Base16Alphabet> = PackedNanoid::pack(&id).unwrap(); + + assert_eq!(packed.as_bytes(), &[0x67, 0x89]); + + let archived = rkyv::to_bytes::(&packed).unwrap(); + assert_eq!(archived.as_slice(), &[0x67, 0x89]); + + let deserialized: PackedNanoid<4, 2, Base16Alphabet> = + rkyv::from_bytes::, Error>(&archived).unwrap(); + assert_eq!(packed, deserialized); + assert_eq!(id, deserialized.unpack().unwrap()); + } + } +}