ferroid/base32/
snowflake.rs

1use super::interface::Base32Ext;
2use crate::{Base32Error, BeBytes, Error, Id, Result, SnowflakeId};
3use core::fmt;
4use core::marker::PhantomData;
5
6/// Extension trait for Crockford Base32 encoding and decoding of ID types.
7///
8/// This trait enables converting IDs backed by integer types into fixed-length,
9/// lexicographically sortable Base32 representation using the [Crockford
10/// Base32](https://www.crockford.com/base32.html) alphabet.
11pub trait Base32SnowExt: SnowflakeId
12where
13    Self::Ty: BeBytes,
14{
15    /// Returns a stack-allocated, zero-initialized buffer for Base32 encoding.
16    ///
17    /// This is a convenience method that returns a [`BeBytes::Base32Array`]
18    /// suitable for use with [`Base32SnowExt::encode_to_buf`]. The returned
19    /// buffer is stack-allocated, has a fixed size known at compile time, and
20    /// is guaranteed to match the Crockford Base32 output size for the backing
21    /// integer type.
22    ///
23    /// See also: [`Base32SnowExt::encode_to_buf`] for usage.
24    #[must_use]
25    fn buf() -> <<Self as Id>::Ty as BeBytes>::Base32Array {
26        <Self as Base32Ext>::inner_buf()
27    }
28    /// Returns a formatter containing the Crockford Base32 representation of
29    /// the ID.
30    ///
31    /// The formatter is a lightweight, zero-allocation view over that internal
32    /// buffer that implements [`core::fmt::Display`] and [`AsRef<str>`].
33    ///
34    /// # Example
35    ///
36    /// ```
37    /// #[cfg(all(feature = "base32", feature = "snowflake"))]
38    /// {
39    ///     use ferroid::{Base32SnowExt, SnowflakeTwitterId};
40    ///     use std::fmt::Write;
41    ///
42    ///     let id = SnowflakeTwitterId::from_raw(2_424_242_424_242_424_242);
43    ///
44    ///     // Formatter is a view over the internal encoded buffer
45    ///     let formatter = id.encode();
46    ///
47    ///     assert_eq!(formatter, "23953MG16DJDJ");
48    /// }
49    /// ```
50    fn encode(&self) -> Base32SnowFormatter<Self> {
51        Base32SnowFormatter::new(self)
52    }
53    /// Encodes this ID into the provided buffer without heap allocation and
54    /// returns a formatter view over the buffer similar to
55    /// [`Base32SnowExt::encode`].
56    ///
57    /// The buffer must be exactly [`BeBytes::BASE32_SIZE`] bytes long, which is
58    /// guaranteed at compile time when using [`Base32SnowExt::buf`].
59    /// # Example
60    ///
61    /// ```
62    /// #[cfg(all(feature = "base32", feature = "snowflake"))]
63    /// {
64    ///     use ferroid::{Base32SnowExt, BeBytes, Id, SnowflakeTwitterId};
65    ///
66    ///     let id = SnowflakeTwitterId::from_raw(2_424_242_424_242_424_242);
67    ///
68    ///     // Stack-allocated buffer of the correct size.
69    ///     let mut buf = SnowflakeTwitterId::buf();
70    ///
71    ///     // Formatter is a view over the external buffer
72    ///     let formatter = id.encode_to_buf(&mut buf);
73    ///
74    ///     assert_eq!(formatter, "23953MG16DJDJ");
75    ///
76    ///     // Or access the raw bytes directly:
77    ///     let as_str = unsafe { core::str::from_utf8_unchecked(buf.as_ref()) };
78    ///     assert_eq!(as_str, "23953MG16DJDJ");
79    /// }
80    /// ```
81    ///
82    /// See also: [`Base32SnowExt::encode`] for a version that manages its own
83    /// buffer.
84    fn encode_to_buf<'buf>(
85        &self,
86        buf: &'buf mut <<Self as Id>::Ty as BeBytes>::Base32Array,
87    ) -> Base32SnowFormatterRef<'buf, Self> {
88        Base32SnowFormatterRef::new(self, buf)
89    }
90    /// Decodes a Base32-encoded string back into an ID.
91    ///
92    /// ⚠️ **Note:**\
93    /// This method structurally decodes a Crockford base32 string into an
94    /// integer representing a Snowflake ID, regardless of whether the input is
95    /// a canonical Snowflake ID.
96    ///
97    /// - If the input string's Crockford encoding is larger than the
98    ///   Snowflake's maximum (i.e. "FZZZZZZZZZZZZ" for 64-bit integers), the
99    ///   excess bit is automatically ignored (i.e., the top 1 bit of the
100    ///   decoded value is discarded), so no overflow or error occurs.
101    /// - As a result, base32 strings that are technically invalid (i.e.,
102    ///   lexicographically greater than the max Snowflake string) will still
103    ///   successfully decode.
104    /// - **However**, if your ID type reserves bits (e.g., reserved or unused
105    ///   bits in your layout), decoding a string with excess bits may set these
106    ///   reserved bits to 1, causing `.is_valid()` to fail, and decode to
107    ///   return an error.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the input string:
112    /// - is not the expected fixed length of the backing integer representation
113    ///   (i.e. 13 chars for u64, 26 chars for u128)
114    /// - contains invalid ASCII characters (i.e., not in the Crockford Base32
115    ///   alphabet)
116    /// - sets reserved bits that make the decoded value invalid for this ID
117    ///   type
118    ///
119    /// # Example
120    ///
121    /// ```
122    /// #[cfg(all(feature = "base32", feature = "snowflake"))]
123    /// {
124    ///    use ferroid::{Base32Error, Base32SnowExt, Error, Id, SnowflakeId, SnowflakeTwitterId};
125    ///
126    ///    // Crockford Base32 encodes values in 5-bit chunks, so encoding a u64
127    ///    // (64 bits)
128    ///    // requires 13 characters (13 × 5 = 65 bits). Since u64 can only hold 64
129    ///    // bits, the highest (leftmost) bit is discarded during decoding.
130    ///    //
131    ///    // This means *any* 13-character Base32 string will decode into a u64, even
132    ///    // if it represents a value that exceeds the canonical range of a specific
133    ///    // ID type.
134    ///    //
135    ///    // Many ID formats (such as Twitter Snowflake IDs) reserve one or more high
136    ///    // bits for future use. These reserved bits **must remain unset** for the
137    ///    // decoded value to be considered valid.
138    ///    //
139    ///    // For example, in a `SnowflakeTwitterId`, "7ZZZZZZZZZZZZ" represents the
140    ///    // largest lexicographically valid encoding that fills all non-reserved bits
141    ///    // with ones. Lexicographically larger values like "QZZZZZZZZZZZZ" decode to
142    ///    // the *same* ID because their first character differs only in the highest
143    ///    // (65th) bit, which is discarded:
144    ///    // - '7' = 0b00111 → top bit 0, reserved bit 0, rest = 111...
145    ///    // - 'Q' = 0b10111 → top bit 1, reserved bit 0, rest = 111...
146    ///    //            ↑↑↑↑ identical after discarding MSB
147    ///    let id1 = SnowflakeTwitterId::decode("7ZZZZZZZZZZZZ").unwrap();
148    ///    let id2 = SnowflakeTwitterId::decode("QZZZZZZZZZZZZ").unwrap();
149    ///    assert_eq!(id1, id2);
150    ///
151    ///    // In contrast, "PZZZZZZZZZZZZ" differs in more significant bits and decodes
152    ///    // to a distinct value:
153    ///    // - 'P' = 0b10110 → top bit 1, reserved bit 0, rest = 110...
154    ///    //               ↑ alters bits within the ID layout beyond the reserved region
155    ///    let id3 = SnowflakeTwitterId::decode("PZZZZZZZZZZZZ").unwrap();
156    ///    assert_ne!(id1, id3);
157    ///
158    ///    // If the reserved bits are set (e.g., 'F' = 0b01111 or 'Z' = 0b11111),
159    ///    // decoding fails and the invalid ID is returned:
160    ///    // - 'F' = 0b01111 → top bit 0, reserved bit 1, rest = 111...
161    ///    //            ↑ reserved bit is set - ID is invalid
162    ///    let id = SnowflakeTwitterId::decode("FZZZZZZZZZZZZ")
163    ///        .or_else(|err| {
164    ///            match err {
165    ///                Error::Base32Error(Base32Error::DecodeOverflow { id }) => {
166    ///                    debug_assert!(!id.is_valid());
167    ///                    // clears reserved bits
168    ///                    let valid = id.into_valid();
169    ///                    debug_assert!(valid.is_valid());
170    ///                    Ok(valid)
171    ///                }
172    ///                other => Err(other),
173    ///            }
174    ///        })
175    ///        .expect("should produce a valid ID");
176    /// }
177    /// ```
178    fn decode(s: impl AsRef<str>) -> Result<Self, Error<Self>> {
179        let decoded = Self::inner_decode(s)?;
180        if !decoded.is_valid() {
181            return Err(Error::Base32Error(Base32Error::DecodeOverflow {
182                id: decoded,
183            }));
184        }
185        Ok(decoded)
186    }
187}
188
189impl<ID> Base32SnowExt for ID
190where
191    ID: SnowflakeId,
192    ID::Ty: BeBytes,
193{
194}
195
196/// A reusable builder that owns the Base32 buffer and formats an ID.
197#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
198pub struct Base32SnowFormatter<T>
199where
200    T: Base32SnowExt,
201    T::Ty: BeBytes,
202{
203    _id: PhantomData<T>,
204    buf: <T::Ty as BeBytes>::Base32Array,
205}
206
207impl<T: Base32SnowExt> Base32SnowFormatter<T>
208where
209    T::Ty: BeBytes,
210{
211    pub fn new(id: &T) -> Self {
212        let mut buf = T::buf();
213        id.inner_encode_to_buf(&mut buf);
214        Self {
215            _id: PhantomData,
216            buf,
217        }
218    }
219
220    /// Returns a `&str` view of the base32 encoding.
221    #[must_use]
222    pub fn as_str(&self) -> &str {
223        // SAFETY: `self.buf` holds only valid Crockford Base32 ASCII characters
224        unsafe { core::str::from_utf8_unchecked(self.buf.as_ref()) }
225    }
226
227    /// Returns an allocated `String` of the base32 encoding.
228    #[cfg(feature = "alloc")]
229    #[cfg_attr(not(feature = "alloc"), doc(hidden))]
230    #[allow(clippy::inherent_to_string_shadow_display)]
231    #[must_use]
232    pub fn to_string(&self) -> alloc::string::String {
233        // SAFETY: `self.buf` holds only valid Crockford Base32 ASCII characters
234        unsafe { alloc::string::String::from_utf8_unchecked(self.buf.as_ref().to_vec()) }
235    }
236
237    /// Consumes the builder and returns the raw buffer.
238    pub const fn into_inner(self) -> <T::Ty as BeBytes>::Base32Array {
239        self.buf
240    }
241}
242
243impl<T: Base32SnowExt> fmt::Display for Base32SnowFormatter<T>
244where
245    T::Ty: BeBytes,
246{
247    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
248        f.write_str(self.as_str())
249    }
250}
251
252impl<T: Base32SnowExt> AsRef<str> for Base32SnowFormatter<T>
253where
254    T::Ty: BeBytes,
255{
256    fn as_ref(&self) -> &str {
257        self.as_str()
258    }
259}
260
261impl<T: Base32SnowExt> PartialEq<&str> for Base32SnowFormatter<T>
262where
263    T::Ty: BeBytes,
264{
265    fn eq(&self, other: &&str) -> bool {
266        self.as_str() == *other
267    }
268}
269
270#[cfg(feature = "std")]
271#[cfg_attr(not(feature = "std"), doc(hidden))]
272impl<T: Base32SnowExt> PartialEq<alloc::string::String> for Base32SnowFormatter<T>
273where
274    T::Ty: BeBytes,
275{
276    fn eq(&self, other: &alloc::string::String) -> bool {
277        self.as_str() == other.as_str()
278    }
279}
280
281/// A builder that borrows a user-supplied buffer for Base32 formatting.
282#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
283pub struct Base32SnowFormatterRef<'a, T>
284where
285    T: Base32SnowExt,
286    T::Ty: BeBytes,
287{
288    _id: PhantomData<T>,
289    buf: &'a <T::Ty as BeBytes>::Base32Array,
290}
291
292impl<'a, T: Base32SnowExt> Base32SnowFormatterRef<'a, T>
293where
294    T::Ty: BeBytes,
295{
296    pub fn new(id: &T, buf: &'a mut <T::Ty as BeBytes>::Base32Array) -> Self {
297        id.inner_encode_to_buf(buf);
298        Self {
299            _id: PhantomData,
300            buf,
301        }
302    }
303
304    /// Returns a `&str` view of the base32 encoding.
305    #[must_use]
306    pub fn as_str(&self) -> &str {
307        // SAFETY: `self.buf` holds only valid Crockford Base32 ASCII characters
308        unsafe { core::str::from_utf8_unchecked(self.buf.as_ref()) }
309    }
310
311    /// Returns an allocated `String` of the base32 encoding.
312    #[cfg(feature = "alloc")]
313    #[cfg_attr(not(feature = "alloc"), doc(hidden))]
314    #[allow(clippy::inherent_to_string_shadow_display)]
315    #[must_use]
316    pub fn to_string(&self) -> alloc::string::String {
317        // SAFETY: `self.buf` holds only valid Crockford Base32 ASCII characters
318        unsafe { alloc::string::String::from_utf8_unchecked(self.buf.as_ref().to_vec()) }
319    }
320}
321
322impl<T: Base32SnowExt> fmt::Display for Base32SnowFormatterRef<'_, T>
323where
324    T::Ty: BeBytes,
325{
326    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327        f.write_str(self.as_str())
328    }
329}
330
331impl<T: Base32SnowExt> AsRef<str> for Base32SnowFormatterRef<'_, T>
332where
333    T::Ty: BeBytes,
334{
335    fn as_ref(&self) -> &str {
336        self.as_str()
337    }
338}
339
340impl<T: Base32SnowExt> PartialEq<str> for Base32SnowFormatterRef<'_, T>
341where
342    T::Ty: BeBytes,
343{
344    fn eq(&self, other: &str) -> bool {
345        self.as_str() == other
346    }
347}
348impl<T: Base32SnowExt> PartialEq<&str> for Base32SnowFormatterRef<'_, T>
349where
350    T::Ty: BeBytes,
351{
352    fn eq(&self, other: &&str) -> bool {
353        self.as_str() == *other
354    }
355}
356
357#[cfg(feature = "alloc")]
358#[cfg_attr(not(feature = "alloc"), doc(hidden))]
359impl<T: Base32SnowExt> PartialEq<alloc::string::String> for Base32SnowFormatterRef<'_, T>
360where
361    T::Ty: BeBytes,
362{
363    fn eq(&self, other: &alloc::string::String) -> bool {
364        self.as_str() == other.as_str()
365    }
366}
367
368#[cfg(all(test, feature = "snowflake"))]
369mod test {
370    use crate::{
371        Base32Error, Base32SnowExt, Error, SnowflakeDiscordId, SnowflakeId, SnowflakeInstagramId,
372        SnowflakeMastodonId, SnowflakeTwitterId,
373    };
374
375    #[test]
376    fn twitter_max() {
377        let id = SnowflakeTwitterId::from_components(
378            SnowflakeTwitterId::max_timestamp(),
379            SnowflakeTwitterId::max_machine_id(),
380            SnowflakeTwitterId::max_sequence(),
381        );
382        assert_eq!(id.timestamp(), SnowflakeTwitterId::max_timestamp());
383        assert_eq!(id.machine_id(), SnowflakeTwitterId::max_machine_id());
384        assert_eq!(id.sequence(), SnowflakeTwitterId::max_sequence());
385
386        let encoded = id.encode();
387        assert_eq!(encoded, "7ZZZZZZZZZZZZ");
388        let decoded = SnowflakeTwitterId::decode(encoded).unwrap();
389
390        assert_eq!(decoded.timestamp(), SnowflakeTwitterId::max_timestamp());
391        assert_eq!(decoded.machine_id(), SnowflakeTwitterId::max_machine_id());
392        assert_eq!(decoded.sequence(), SnowflakeTwitterId::max_sequence());
393        assert_eq!(id, decoded);
394    }
395
396    #[test]
397    fn twitter_zero() {
398        let id = SnowflakeTwitterId::from_components(0, 0, 0);
399        assert_eq!(id.timestamp(), 0);
400        assert_eq!(id.machine_id(), 0);
401        assert_eq!(id.sequence(), 0);
402
403        let encoded = id.encode();
404        assert_eq!(encoded, "0000000000000");
405        let decoded = SnowflakeTwitterId::decode(encoded).unwrap();
406
407        assert_eq!(decoded.timestamp(), 0);
408        assert_eq!(decoded.machine_id(), 0);
409        assert_eq!(decoded.sequence(), 0);
410        assert_eq!(id, decoded);
411    }
412
413    #[test]
414    fn discord_max() {
415        let id = SnowflakeDiscordId::from_components(
416            SnowflakeDiscordId::max_timestamp(),
417            SnowflakeDiscordId::max_machine_id(),
418            SnowflakeDiscordId::max_sequence(),
419        );
420        assert_eq!(id.timestamp(), SnowflakeDiscordId::max_timestamp());
421        assert_eq!(id.machine_id(), SnowflakeDiscordId::max_machine_id());
422        assert_eq!(id.sequence(), SnowflakeDiscordId::max_sequence());
423
424        let encoded = id.encode();
425        assert_eq!(encoded, "FZZZZZZZZZZZZ");
426        let decoded = SnowflakeDiscordId::decode(encoded).unwrap();
427
428        assert_eq!(decoded.timestamp(), SnowflakeDiscordId::max_timestamp());
429        assert_eq!(decoded.machine_id(), SnowflakeDiscordId::max_machine_id());
430        assert_eq!(decoded.sequence(), SnowflakeDiscordId::max_sequence());
431        assert_eq!(id, decoded);
432    }
433
434    #[test]
435    fn discord_zero() {
436        let id = SnowflakeDiscordId::from_components(0, 0, 0);
437        assert_eq!(id.timestamp(), 0);
438        assert_eq!(id.machine_id(), 0);
439        assert_eq!(id.sequence(), 0);
440
441        let encoded = id.encode();
442        assert_eq!(encoded, "0000000000000");
443        let decoded = SnowflakeDiscordId::decode(encoded).unwrap();
444
445        assert_eq!(decoded.timestamp(), 0);
446        assert_eq!(decoded.machine_id(), 0);
447        assert_eq!(decoded.sequence(), 0);
448        assert_eq!(id, decoded);
449    }
450
451    #[test]
452    fn instagram_max() {
453        let id = SnowflakeInstagramId::from_components(
454            SnowflakeInstagramId::max_timestamp(),
455            SnowflakeInstagramId::max_machine_id(),
456            SnowflakeInstagramId::max_sequence(),
457        );
458        assert_eq!(id.timestamp(), SnowflakeInstagramId::max_timestamp());
459        assert_eq!(id.machine_id(), SnowflakeInstagramId::max_machine_id());
460        assert_eq!(id.sequence(), SnowflakeInstagramId::max_sequence());
461
462        let encoded = id.encode();
463        assert_eq!(encoded, "FZZZZZZZZZZZZ");
464        let decoded = SnowflakeInstagramId::decode(encoded).unwrap();
465
466        assert_eq!(decoded.timestamp(), SnowflakeInstagramId::max_timestamp());
467        assert_eq!(decoded.machine_id(), SnowflakeInstagramId::max_machine_id());
468        assert_eq!(decoded.sequence(), SnowflakeInstagramId::max_sequence());
469        assert_eq!(id, decoded);
470    }
471
472    #[test]
473    fn instagram_zero() {
474        let id = SnowflakeInstagramId::from_components(0, 0, 0);
475        assert_eq!(id.timestamp(), 0);
476        assert_eq!(id.machine_id(), 0);
477        assert_eq!(id.sequence(), 0);
478
479        let encoded = id.encode();
480        assert_eq!(encoded, "0000000000000");
481        let decoded = SnowflakeInstagramId::decode(encoded).unwrap();
482
483        assert_eq!(decoded.timestamp(), 0);
484        assert_eq!(decoded.machine_id(), 0);
485        assert_eq!(decoded.sequence(), 0);
486        assert_eq!(id, decoded);
487    }
488
489    #[test]
490    fn mastodon_max() {
491        let id = SnowflakeMastodonId::from_components(
492            SnowflakeMastodonId::max_timestamp(),
493            SnowflakeMastodonId::max_machine_id(),
494            SnowflakeMastodonId::max_sequence(),
495        );
496        assert_eq!(id.timestamp(), SnowflakeMastodonId::max_timestamp());
497        assert_eq!(id.machine_id(), SnowflakeMastodonId::max_machine_id());
498        assert_eq!(id.sequence(), SnowflakeMastodonId::max_sequence());
499
500        let encoded = id.encode();
501        assert_eq!(encoded, "FZZZZZZZZZZZZ");
502        let decoded = SnowflakeMastodonId::decode(encoded).unwrap();
503
504        assert_eq!(decoded.timestamp(), SnowflakeMastodonId::max_timestamp());
505        assert_eq!(decoded.machine_id(), SnowflakeMastodonId::max_machine_id());
506        assert_eq!(decoded.sequence(), SnowflakeMastodonId::max_sequence());
507        assert_eq!(id, decoded);
508    }
509
510    #[test]
511    fn mastodon_zero() {
512        let id = SnowflakeMastodonId::from_components(0, 0, 0);
513        assert_eq!(id.timestamp(), 0);
514        assert_eq!(id.machine_id(), 0);
515        assert_eq!(id.sequence(), 0);
516
517        let encoded = id.encode();
518        assert_eq!(encoded, "0000000000000");
519        let decoded = SnowflakeMastodonId::decode(encoded).unwrap();
520
521        assert_eq!(decoded.timestamp(), 0);
522        assert_eq!(decoded.machine_id(), 0);
523        assert_eq!(decoded.sequence(), 0);
524        assert_eq!(id, decoded);
525    }
526
527    #[test]
528    fn decode_invalid_character_fails() {
529        // Base32 Crockford disallows symbols like `@`
530        let invalid = "012345678901@";
531        let result = SnowflakeTwitterId::decode(invalid);
532        assert!(matches!(
533            result,
534            Err(Error::Base32Error(Base32Error::DecodeInvalidAscii {
535                byte: 64
536            }))
537        ));
538    }
539
540    #[test]
541    fn decode_invalid_length_fails() {
542        // Shorter than 13-byte base32 for u64 (decoded slice won't be 8 bytes)
543        let too_short = "012345678901";
544        let result = SnowflakeTwitterId::decode(too_short);
545        assert!(matches!(
546            result,
547            Err(Error::Base32Error(Base32Error::DecodeInvalidLen {
548                len: 12
549            }))
550        ));
551
552        // Longer than 13-byte base32 for u64 (decoded slice won't be 8 bytes)
553        let too_long = "01234567890123";
554        let result = SnowflakeTwitterId::decode(too_long);
555        assert!(matches!(
556            result,
557            Err(Error::Base32Error(Base32Error::DecodeInvalidLen {
558                len: 14
559            }))
560        ));
561    }
562}