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}