twilight_model/util/
image_hash.rs

1//! Efficient parsing and storage of Discord image hashes.
2//!
3//! In a simple implementation, Discord image hashes are stored in a
4//! [`std::string::String`]. Using an [`ImageHash`], only 17 bytes are required
5//! to store a Discord hash.
6//!
7//! [`ImageHash::parse`] is used to parse provided bytes, along with
8//! [`std::convert::TryFrom`], [`std::str::FromStr`], and `serde`
9//! implementations. The input is assumed to be only in ASCII format.
10//! [`ImageHash::bytes`] and [`ImageHash::is_animated`] may be used to
11//! deconstruct the hash and [`ImageHash::new`] may be used to reconstruct one.
12
13#![allow(dead_code, unused_mut)]
14
15use serde::{
16    de::{Deserialize, Deserializer, Error as DeError, Visitor},
17    ser::{Serialize, Serializer},
18};
19use std::{
20    error::Error,
21    fmt::{Display, Formatter, Result as FmtResult},
22    str::FromStr,
23};
24
25/// Key indicating an animated image hash.
26const ANIMATED_KEY: &str = "a_";
27
28/// Length of an image hash.
29const HASH_LEN: usize = 32;
30
31/// Parsing an image hash into an efficient storage format via
32/// [`ImageHash::parse`] failed.
33#[derive(Debug)]
34pub struct ImageHashParseError {
35    kind: ImageHashParseErrorType,
36}
37
38impl ImageHashParseError {
39    /// Error with an [`ImageHashParseErrorType::Format`] error type.
40    const FORMAT: Self = ImageHashParseError {
41        kind: ImageHashParseErrorType::Format,
42    };
43
44    /// Immutable reference to the type of error that occurred.
45    #[must_use = "retrieving the type has no effect if left unused"]
46    pub const fn kind(&self) -> &ImageHashParseErrorType {
47        &self.kind
48    }
49
50    /// Consume the error, returning the source error if there is any.
51    #[allow(clippy::unused_self)]
52    #[must_use = "consuming the error and retrieving the source has no effect if left unused"]
53    pub fn into_source(self) -> Option<Box<dyn Error + Send + Sync>> {
54        None
55    }
56
57    /// Consume the error, returning the owned error type and the source error.
58    #[must_use = "consuming the error into its parts has no effect if left unused"]
59    pub fn into_parts(
60        self,
61    ) -> (
62        ImageHashParseErrorType,
63        Option<Box<dyn Error + Send + Sync>>,
64    ) {
65        (self.kind, None)
66    }
67
68    /// Instantiate a new error with an [`ImageHashParseErrorType::Range`] error
69    /// type.
70    const fn range(index: usize, value: u8) -> Self {
71        Self {
72            kind: ImageHashParseErrorType::Range { index, value },
73        }
74    }
75}
76
77impl Display for ImageHashParseError {
78    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
79        match self.kind {
80            ImageHashParseErrorType::Format => {
81                f.write_str("image hash isn't in a discord image hash format")
82            }
83            ImageHashParseErrorType::Range { index, value } => {
84                f.write_str("value (")?;
85                Display::fmt(&value, f)?;
86                f.write_str(") at encountered index (")?;
87                Display::fmt(&index, f)?;
88
89                f.write_str(") is not an acceptable value")
90            }
91        }
92    }
93}
94
95impl Error for ImageHashParseError {}
96
97/// Type of [`ImageHashParseError`] that occurred.
98#[derive(Debug)]
99#[non_exhaustive]
100pub enum ImageHashParseErrorType {
101    /// Input is either animated and not 34 characters long or is not animated
102    /// and is not 32 characters long.
103    Format,
104    /// Input is out of the accepted range ('0' to '9', 'a' to 'f').
105    Range {
106        /// Index of the byte.
107        index: usize,
108        /// Byte of the value out of the acceptable range.
109        value: u8,
110    },
111}
112
113/// Efficient storage for Discord image hashes.
114///
115/// This works by storing image hashes as packed integers rather than
116/// heap-allocated [`std::string::String`]s.
117///
118/// Parsing methods only support hashes provided by Discord's APIs.
119///
120/// Clyde AI has a unique hash that doesn't match the patterns of other hashes,
121/// uniquely processed as [`ImageHash::CLYDE`].
122#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
123pub struct ImageHash {
124    /// Whether the image is animated.
125    ///
126    /// This is denoted in the input by a prefixed `a_`.
127    animated: bool,
128    bytes: [u8; 16],
129}
130
131impl ImageHash {
132    /// Avatar hash of Clyde AI, which has a non-standard hash.
133    pub const CLYDE: Self = Self {
134        animated: true,
135        bytes: {
136            let mut bytes = [0; 16];
137            bytes[0] = b'c';
138            bytes[1] = b'l';
139            bytes[2] = b'y';
140            bytes[3] = b'd';
141            bytes[4] = b'e';
142
143            bytes
144        },
145    };
146
147    /// Instantiate a new hash from its raw parts.
148    ///
149    /// Parts can be obtained via [`is_animated`] and [`bytes`].
150    ///
151    /// # Examples
152    ///
153    /// Parse an image hash, deconstruct it, and then reconstruct it:
154    ///
155    /// ```
156    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
157    /// use twilight_model::util::ImageHash;
158    ///
159    /// let input = "1acefe340fafb4ecefae407f3abdb323";
160    /// let parsed = ImageHash::parse(input.as_bytes())?;
161    ///
162    /// let (bytes, is_animated) = (parsed.bytes(), parsed.is_animated());
163    ///
164    /// let constructed = ImageHash::new(bytes, is_animated);
165    /// assert_eq!(input, constructed.to_string());
166    /// # Ok(()) }
167    /// ```
168    ///
169    /// [`is_animated`]: Self::is_animated
170    /// [`bytes`]: Self::bytes
171    pub const fn new(bytes: [u8; 16], animated: bool) -> Self {
172        Self { animated, bytes }
173    }
174
175    /// Parse an image hash into an efficient integer-based storage.
176    ///
177    /// # Examples
178    ///
179    /// Parse a static image hash:
180    ///
181    /// ```
182    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
183    /// use twilight_model::util::ImageHash;
184    ///
185    /// let input = "b2a6536641da91a0b59bd66557c56c36";
186    /// let parsed = ImageHash::parse(input.as_bytes())?;
187    ///
188    /// assert!(!parsed.is_animated());
189    /// assert_eq!(input, parsed.to_string());
190    /// # Ok(()) }
191    /// ```
192    ///
193    /// Parse an animated image hash:
194    ///
195    /// ```
196    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
197    /// use twilight_model::util::ImageHash;
198    ///
199    /// let input = "a_b2a6536641da91a0b59bd66557c56c36";
200    /// let parsed = ImageHash::parse(input.as_bytes())?;
201    ///
202    /// assert!(parsed.is_animated());
203    /// assert_eq!(input, parsed.to_string());
204    /// # Ok(()) }
205    /// ```
206    ///
207    /// # Errors
208    ///
209    /// Returns an [`ImageHashParseErrorType::Format`] error type if the
210    /// provided value isn't in a Discord image hash format. Refer to the
211    /// variant's documentation for more details.
212    ///
213    /// Returns an [`ImageHashParseErrorType::Range`] error type if one of the
214    /// hex values is outside of the accepted range. Refer to the variant's
215    /// documentation for more details.
216    pub const fn parse(value: &[u8]) -> Result<Self, ImageHashParseError> {
217        /// Number of digits allocated in the half-byte.
218        ///
219        /// In other words, the number of numerical representations before
220        /// reaching alphabetical representations in the half-byte.
221        const DIGITS_ALLOCATED: u8 = 10;
222
223        if Self::is_clyde(value) {
224            return Ok(Self::CLYDE);
225        }
226
227        let animated = Self::starts_with(value, ANIMATED_KEY.as_bytes());
228
229        let mut seeking_idx = if animated { ANIMATED_KEY.len() } else { 0 };
230        // We start at the end because hashes are stored in little endian.
231        let mut storage_idx = 15;
232
233        if value.len() - seeking_idx != HASH_LEN {
234            return Err(ImageHashParseError::FORMAT);
235        }
236
237        let mut bytes = [0; 16];
238
239        while seeking_idx < value.len() {
240            let byte_left = match value[seeking_idx] {
241                byte @ b'0'..=b'9' => byte - b'0',
242                byte @ b'a'..=b'f' => byte - b'a' + DIGITS_ALLOCATED,
243                other => return Err(ImageHashParseError::range(seeking_idx, other)),
244            };
245            seeking_idx += 1;
246            let byte_right = match value[seeking_idx] {
247                byte @ b'0'..=b'9' => byte - b'0',
248                byte @ b'a'..=b'f' => byte - b'a' + DIGITS_ALLOCATED,
249                other => return Err(ImageHashParseError::range(seeking_idx, other)),
250            };
251
252            bytes[storage_idx] = (byte_left << 4) | byte_right;
253            seeking_idx += 1;
254            storage_idx = storage_idx.saturating_sub(1);
255        }
256
257        Ok(Self { animated, bytes })
258    }
259
260    /// Efficient packed bytes of the hash.
261    ///
262    /// Can be paired with [`is_animated`] for use in [`new`] to recreate the
263    /// efficient image hash.
264    ///
265    /// # Examples
266    ///
267    /// Parse an image hash and then check the packed bytes:
268    ///
269    /// ```
270    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
271    /// use twilight_model::util::ImageHash;
272    ///
273    /// let input = b"f49d812ca33c1cbbeec96b9f64487c7c";
274    /// let hash = ImageHash::parse(input)?;
275    /// let bytes = hash.bytes();
276    ///
277    /// // Byte correlates to 12 (c) followed by 7 (7).
278    /// assert_eq!(0b0111_1100, bytes[0]);
279    ///
280    /// // Byte correlates to 4 (4) followed by 15 (f).
281    /// assert_eq!(0b1111_0100, bytes[15]);
282    /// # Ok(()) }
283    /// ```
284    ///
285    /// [`is_animated`]: Self::is_animated
286    /// [`new`]: Self::new
287    pub const fn bytes(self) -> [u8; 16] {
288        self.bytes
289    }
290
291    /// Whether the hash is for an animated image.
292    ///
293    /// # Examples
294    ///
295    /// Parse an animated image hash prefixed with `a_` and another static image
296    /// hash that is not prefixed with `a_`:
297    ///
298    /// ```
299    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
300    /// use twilight_model::util::ImageHash;
301    ///
302    /// let animated_input = "a_5145104ad8e8c9e765883813e4abbcc8";
303    /// let animated_hash = ImageHash::parse(animated_input.as_bytes())?;
304    /// assert!(animated_hash.is_animated());
305    ///
306    /// let static_input = "c7e7c4b8469d790cb9b293759e60953d";
307    /// let static_hash = ImageHash::parse(static_input.as_bytes())?;
308    /// assert!(!static_hash.is_animated());
309    /// # Ok(()) }
310    /// ```
311    pub const fn is_animated(self) -> bool {
312        self.animated
313    }
314
315    /// Create an iterator over the [nibbles] of the hexadecimal image hash.
316    ///
317    /// # Examples
318    ///
319    /// Parse an image hash and then iterate over the nibbles:
320    ///
321    /// ```
322    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
323    /// use twilight_model::util::ImageHash;
324    ///
325    /// let input = b"1d9811c4cd3782148915c522b02878fc";
326    /// let hash = ImageHash::parse(input)?;
327    /// let mut nibbles = hash.nibbles();
328    ///
329    /// assert_eq!(Some(b'1'), nibbles.next());
330    /// assert_eq!(Some(b'd'), nibbles.next());
331    /// assert_eq!(Some(b'c'), nibbles.nth(29));
332    /// assert!(nibbles.next().is_none());
333    /// # Ok(()) }
334    /// ```
335    ///
336    /// [nibbles]: https://en.wikipedia.org/wiki/Nibble
337    pub const fn nibbles(self) -> Nibbles {
338        Nibbles::new(self)
339    }
340
341    /// Determine whether a value is the Clyde AI image hash.
342    const fn is_clyde(value: &[u8]) -> bool {
343        if value.len() < 5 {
344            return false;
345        }
346
347        value[0] == b'c'
348            && value[1] == b'l'
349            && value[2] == b'y'
350            && value[3] == b'd'
351            && value[4] == b'e'
352    }
353
354    /// Determine whether a haystack starts with a needle.
355    const fn starts_with(haystack: &[u8], needle: &[u8]) -> bool {
356        if needle.len() > haystack.len() {
357            return false;
358        }
359
360        let mut idx = 0;
361
362        while idx < needle.len() {
363            if haystack[idx] != needle[idx] {
364                return false;
365            }
366
367            idx += 1;
368        }
369
370        true
371    }
372}
373
374impl<'de> Deserialize<'de> for ImageHash {
375    /// Parse an image hash string into an efficient decimal store.
376    ///
377    /// # Examples
378    ///
379    /// Refer to [`ImageHash::parse`]'s documentation for examples.
380    ///
381    /// # Errors
382    ///
383    /// Returns an [`ImageHashParseErrorType::Format`] error type if the
384    /// provided value isn't in a Discord image hash format. Refer to the
385    /// variant's documentation for more details.
386    ///
387    /// Returns an [`ImageHashParseErrorType::Range`] error type if one of the
388    /// hex values is outside of the accepted range. Refer to the variant's
389    /// documentation for more details.
390    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
391        struct ImageHashVisitor;
392
393        impl Visitor<'_> for ImageHashVisitor {
394            type Value = ImageHash;
395
396            fn expecting(&self, f: &mut Formatter<'_>) -> FmtResult {
397                f.write_str("image hash")
398            }
399
400            fn visit_str<E: DeError>(self, v: &str) -> Result<Self::Value, E> {
401                ImageHash::parse(v.as_bytes()).map_err(DeError::custom)
402            }
403        }
404
405        deserializer.deserialize_any(ImageHashVisitor)
406    }
407}
408
409impl Display for ImageHash {
410    /// Format the image hash as a hex string.
411    ///
412    /// # Examples
413    ///
414    /// Parse a hash and then format it to ensure it matches the input:
415    ///
416    /// ```
417    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
418    /// use twilight_model::util::ImageHash;
419    ///
420    /// let input = "a_b0e09d6697b11e9c79a89e5e3756ddee";
421    /// let parsed = ImageHash::parse(input.as_bytes())?;
422    ///
423    /// assert_eq!(input, parsed.to_string());
424    /// # Ok(()) }
425    /// ```
426    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
427        if *self == Self::CLYDE {
428            return f.write_str("clyde");
429        }
430
431        if self.is_animated() {
432            f.write_str(ANIMATED_KEY)?;
433        }
434
435        for hex_value in self.nibbles() {
436            let legible = char::from(hex_value);
437
438            Display::fmt(&legible, f)?;
439        }
440
441        Ok(())
442    }
443}
444
445impl FromStr for ImageHash {
446    type Err = ImageHashParseError;
447
448    /// Parse an image hash string into an efficient decimal store.
449    ///
450    /// # Examples
451    ///
452    /// Refer to [`ImageHash::parse`]'s documentation for examples.
453    ///
454    /// # Errors
455    ///
456    /// Returns an [`ImageHashParseErrorType::Format`] error type if the
457    /// provided value isn't in a Discord image hash format. Refer to the
458    /// variant's documentation for more details.
459    ///
460    /// Returns an [`ImageHashParseErrorType::Range`] error type if one of the
461    /// hex values is outside of the accepted range. Refer to the variant's
462    /// documentation for more details.
463    fn from_str(s: &str) -> Result<Self, Self::Err> {
464        Self::parse(s.as_bytes())
465    }
466}
467
468impl Serialize for ImageHash {
469    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
470        serializer.collect_str(self)
471    }
472}
473
474impl TryFrom<&[u8]> for ImageHash {
475    type Error = ImageHashParseError;
476
477    /// Parse an image hash string into an efficient decimal store.
478    ///
479    /// # Examples
480    ///
481    /// Refer to [`ImageHash::parse`]'s documentation for examples.
482    ///
483    /// # Errors
484    ///
485    /// Returns an [`ImageHashParseErrorType::Format`] error type if the
486    /// provided value isn't in a Discord image hash format. Refer to the
487    /// variant's documentation for more details.
488    ///
489    /// Returns an [`ImageHashParseErrorType::Range`] error type if one of the
490    /// hex values is outside of the accepted range. Refer to the variant's
491    /// documentation for more details.
492    fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
493        Self::parse(value)
494    }
495}
496
497impl TryFrom<&str> for ImageHash {
498    type Error = ImageHashParseError;
499
500    /// Parse an image hash string into an efficient decimal store.
501    ///
502    /// # Examples
503    ///
504    /// Refer to [`ImageHash::parse`]'s documentation for examples.
505    ///
506    /// # Errors
507    ///
508    /// Returns an [`ImageHashParseErrorType::Format`] error type if the
509    /// provided value isn't in a Discord image hash format. Refer to the
510    /// variant's documentation for more details.
511    ///
512    /// Returns an [`ImageHashParseErrorType::Range`] error type if one of the
513    /// hex values is outside of the accepted range. Refer to the variant's
514    /// documentation for more details.
515    fn try_from(value: &str) -> Result<Self, Self::Error> {
516        Self::try_from(value.as_bytes())
517    }
518}
519
520/// Iterator over the [nibbles] of an image hash.
521///
522/// # Examples
523///
524/// Parse an image hash and then iterate over the nibbles:
525///
526/// ```
527/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
528/// use twilight_model::util::ImageHash;
529///
530/// let input = b"1d9811c4cd3782148915c522b02878fc";
531/// let hash = ImageHash::parse(input)?;
532/// let mut nibbles = hash.nibbles();
533///
534/// assert_eq!(Some(b'1'), nibbles.next());
535/// assert_eq!(Some(b'd'), nibbles.next());
536/// assert_eq!(Some(b'c'), nibbles.nth(29));
537/// assert!(nibbles.next().is_none());
538/// # Ok(()) }
539/// ```
540///
541/// [nibbles]: https://en.wikipedia.org/wiki/Nibble
542#[derive(Debug)]
543pub struct Nibbles {
544    /// Current index of the iterator.
545    idx: usize,
546    /// Hash being iterated over.
547    inner: ImageHash,
548}
549
550impl Nibbles {
551    /// Create a new iterator over an image hash, creating nibbles.
552    const fn new(inner: ImageHash) -> Self {
553        Self {
554            idx: usize::MAX,
555            inner,
556        }
557    }
558
559    /// Advance the index in the iterator by the provided amount.
560    fn advance_idx_by(&mut self, by: usize) {
561        self.idx = if self.idx == usize::MAX {
562            0
563        } else {
564            let mut new_idx = self.idx.saturating_add(by);
565
566            if new_idx == usize::MAX {
567                new_idx -= 1;
568            }
569
570            new_idx
571        }
572    }
573
574    /// Parse the byte at the stored index.
575    const fn byte(&self) -> Option<u8> {
576        const BITS_IN_HALF_BYTE: u8 = 4;
577
578        /// Greatest index that the hash byte array can be indexed into.
579        const BYTE_ARRAY_BOUNDARY: usize = HASH_LEN - 1;
580
581        const RIGHT_MASK: u8 = (1 << BITS_IN_HALF_BYTE) - 1;
582
583        if self.idx >= HASH_LEN {
584            return None;
585        }
586
587        let (byte, left) = ((BYTE_ARRAY_BOUNDARY - self.idx) / 2, self.idx % 2 == 0);
588
589        let store = self.inner.bytes[byte];
590
591        let bits = if left {
592            store >> BITS_IN_HALF_BYTE
593        } else {
594            store & RIGHT_MASK
595        };
596
597        Some(Self::nibble(bits))
598    }
599
600    /// Convert 4 bits in a byte integer to a nibble.
601    ///
602    /// Values 0-9 correlate to representations '0' through '9', while values
603    /// 10-15 correlate to representations 'a' through 'f'.
604    const fn nibble(value: u8) -> u8 {
605        if value < 10 {
606            b'0' + value
607        } else {
608            b'a' + (value - 10)
609        }
610    }
611}
612
613impl DoubleEndedIterator for Nibbles {
614    fn next_back(&mut self) -> Option<Self::Item> {
615        if self.idx == usize::MAX {
616            return None;
617        }
618
619        self.idx = self.idx.checked_sub(1)?;
620
621        self.byte()
622    }
623}
624
625impl ExactSizeIterator for Nibbles {
626    fn len(&self) -> usize {
627        HASH_LEN
628    }
629}
630
631impl Iterator for Nibbles {
632    type Item = u8;
633
634    fn next(&mut self) -> Option<Self::Item> {
635        self.advance_idx_by(1);
636
637        self.byte()
638    }
639
640    // Optimization to avoid the iterator from calling `next` n times.
641    fn nth(&mut self, n: usize) -> Option<Self::Item> {
642        self.advance_idx_by(n.saturating_add(1));
643
644        self.byte()
645    }
646
647    fn size_hint(&self) -> (usize, Option<usize>) {
648        (HASH_LEN, Some(HASH_LEN))
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::{ImageHash, ImageHashParseError, ImageHashParseErrorType, Nibbles};
655    use static_assertions::assert_impl_all;
656    use std::{
657        error::Error,
658        fmt::{Debug, Display},
659        hash::Hash,
660    };
661
662    assert_impl_all!(
663        Nibbles: Debug,
664        DoubleEndedIterator,
665        ExactSizeIterator,
666        Iterator,
667        Send,
668        Sync
669    );
670    assert_impl_all!(ImageHashParseErrorType: Debug, Send, Sync);
671    assert_impl_all!(ImageHashParseError: Error, Send, Sync);
672    assert_impl_all!(
673        ImageHash: Clone,
674        Debug,
675        Display,
676        Eq,
677        Hash,
678        PartialEq,
679        Send,
680        Sync
681    );
682
683    /// Test that reconstruction of parted hashes is correct.
684    #[test]
685    fn new() -> Result<(), ImageHashParseError> {
686        let source = ImageHash::parse(b"85362c0262ef125a1182b1fad66b6a89")?;
687        let (bytes, animated) = (source.bytes(), source.is_animated());
688
689        let reconstructed = ImageHash::new(bytes, animated);
690        assert_eq!(reconstructed, source);
691
692        Ok(())
693    }
694
695    #[test]
696    fn parse() -> Result<(), ImageHashParseError> {
697        let actual = ImageHash::parse(b"77450a7713f093adaebab32b18dacc46")?;
698        let expected = [
699            70, 204, 218, 24, 43, 179, 186, 174, 173, 147, 240, 19, 119, 10, 69, 119,
700        ];
701        assert_eq!(actual.bytes(), expected);
702
703        Ok(())
704    }
705
706    #[test]
707    fn display() -> Result<(), ImageHashParseError> {
708        assert_eq!(
709            "58ec815c650e72f8eb31eec52e54b3b5",
710            ImageHash::parse(b"58ec815c650e72f8eb31eec52e54b3b5")?.to_string()
711        );
712        assert_eq!(
713            "a_e382aeb1574bf3e4fe852f862bc4919c",
714            ImageHash::parse(b"a_e382aeb1574bf3e4fe852f862bc4919c")?.to_string()
715        );
716
717        Ok(())
718    }
719
720    /// Test that various formats are indeed invalid.
721    #[test]
722    fn parse_format() {
723        const INPUTS: &[&[u8]] = &[
724            b"not correct length",
725            b"",
726            b"a_",
727            // `a_` followed by 33 bytes.
728            b"a_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
729            // 31 bytes.
730            b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
731        ];
732
733        for input in INPUTS {
734            assert!(matches!(
735                ImageHash::parse(input).unwrap_err().kind(),
736                &ImageHashParseErrorType::Format
737            ));
738        }
739    }
740
741    #[test]
742    fn parse_range() {
743        let mut input = [b'a'; 32];
744        input[17] = b'-';
745
746        assert!(matches!(
747            ImageHash::parse(&input).unwrap_err().kind(),
748            ImageHashParseErrorType::Range {
749                index: 17,
750                value: b'-',
751            }
752        ));
753    }
754
755    #[test]
756    fn nibbles() -> Result<(), ImageHashParseError> {
757        const INPUT: &[u8] = b"39eb706d6fbaeb22837c350993b97b42";
758
759        let hash = ImageHash::parse(INPUT)?;
760        let mut iter = hash.nibbles();
761
762        for byte in INPUT.iter().copied() {
763            assert_eq!(Some(byte), iter.next());
764        }
765
766        assert!(iter.next().is_none());
767
768        Ok(())
769    }
770
771    /// Test that the [`core::iter::DoubleEndedIterator`] implementation on
772    /// [`Nibbles`] functions like a double ended iterator should.
773    #[test]
774    fn nibbles_double_ended() -> Result<(), ImageHashParseError> {
775        const INPUT: &[u8] = b"e72bbdec903c420b7aa9c45fc7994ac8";
776
777        let hash = ImageHash::parse(INPUT)?;
778        let mut iter = hash.nibbles();
779
780        // Since we haven't started the iterator there should be no previous
781        // item.
782        assert!(iter.next_back().is_none());
783
784        // This should be index 0 in the input.
785        assert_eq!(Some(b'e'), iter.next());
786
787        // We're at index 0, so there's nothing before that.
788        assert!(iter.next_back().is_none());
789
790        // Try somewhere in the middle.
791        assert_eq!(Some(b'e'), iter.nth(5));
792
793        // Skip all the way past the rest of the input to the last byte.
794        assert_eq!(Some(b'8'), iter.nth(24));
795
796        // Now that we're at the end, any additional retrievals will return no
797        // item.
798        assert!(iter.next().is_none());
799
800        // Last input.
801        assert_eq!(Some(b'8'), iter.next_back());
802
803        // And finally, the next one should be None again.
804        assert!(iter.next().is_none());
805
806        Ok(())
807    }
808
809    /// Test that image hash parsing correctly identifies animated hashes by its
810    /// `a_` prefix.
811    #[test]
812    fn is_animated() -> Result<(), ImageHashParseError> {
813        assert!(ImageHash::parse(b"a_06c16474723fe537c283b8efa61a30c8")?.is_animated());
814        assert!(!ImageHash::parse(b"06c16474723fe537c283b8efa61a30c8")?.is_animated());
815
816        Ok(())
817    }
818
819    /// Test that Clyde AI avatar hashes correctly parse as
820    /// [`ImageHash::CLYDE`].
821    #[test]
822    fn clyde() -> Result<(), ImageHashParseError> {
823        assert_eq!(ImageHash::CLYDE, ImageHash::parse(b"clyde")?);
824        serde_test::assert_tokens(&ImageHash::CLYDE, &[serde_test::Token::Str("clyde")]);
825
826        Ok(())
827    }
828}