Skip to main content

qubit_codec_misc/
hex_codec.rs

1// =============================================================================
2//    Copyright (c) 2026 Haixing Hu.
3//
4//    SPDX-License-Identifier: Apache-2.0
5//
6//    Licensed under the Apache License, Version 2.0.
7// =============================================================================
8//! Hexadecimal byte codec.
9
10use qubit_codec::ValueDecoder;
11use qubit_codec::ValueEncoder;
12
13use crate::MiscCodecError;
14use crate::MiscCodecResult;
15use crate::hex_codec_config::HexCodecConfig;
16
17const LOWER_HEX_DIGITS: [char; 16] = [
18    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e',
19    'f',
20];
21
22const UPPER_HEX_DIGITS: [char; 16] = [
23    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E',
24    'F',
25];
26
27/// Encodes and decodes hexadecimal byte strings.
28///
29/// `HexCodec` is an owned facade for whole byte slices. Whole-string prefix,
30/// per-byte prefix, separator, and whitespace handling are part of
31/// [`encode`](Self::encode) and [`decode`](Self::decode). Use
32/// [`crate::HexByteCodec`] when a low-level
33/// [`qubit_codec::Codec<Value = u8, Unit = u8>`] is
34/// required.
35#[must_use]
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct HexCodec {
38    config: HexCodecConfig,
39}
40
41impl HexCodec {
42    /// Creates a lowercase codec without prefix or separators.
43    ///
44    /// # Returns
45    /// A hexadecimal codec using lowercase digits.
46    #[inline]
47    pub fn new() -> Self {
48        Self::from_config(HexCodecConfig::new())
49    }
50
51    /// Creates an uppercase codec without prefix or separators.
52    ///
53    /// # Returns
54    /// A hexadecimal codec using uppercase digits.
55    #[inline]
56    pub fn upper() -> Self {
57        Self::from_config(HexCodecConfig::new().with_uppercase(true))
58    }
59
60    /// Creates a codec from an explicit configuration.
61    #[inline]
62    pub fn from_config(config: HexCodecConfig) -> Self {
63        Self { config }
64    }
65
66    /// Returns the configuration used by this codec.
67    #[inline]
68    pub fn config(&self) -> &HexCodecConfig {
69        &self.config
70    }
71
72    /// Sets whether encoded digits should be uppercase.
73    ///
74    /// # Parameters
75    /// - `uppercase`: Whether to use uppercase hexadecimal digits.
76    ///
77    /// # Returns
78    /// The updated codec.
79    #[inline]
80    pub fn with_uppercase(mut self, uppercase: bool) -> Self {
81        self.config = self.config.with_uppercase(uppercase);
82        self
83    }
84
85    /// Sets a whole-output prefix.
86    ///
87    /// The prefix is written once before the encoded bytes and required once
88    /// before decoded input. For example, using prefix `0x` encodes bytes as
89    /// `0x1f8b`.
90    ///
91    /// # Parameters
92    /// - `prefix`: Whole-output prefix text such as `0x`.
93    ///
94    /// # Returns
95    /// The updated codec.
96    #[inline]
97    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
98        self.config = self.config.with_prefix(prefix);
99        self
100    }
101
102    /// Sets a per-byte prefix.
103    ///
104    /// The prefix is written before every encoded byte and required before
105    /// every decoded byte. For example, using byte prefix `0x` and separator
106    /// ` ` encodes bytes as `0x1f 0x8b`.
107    ///
108    /// # Parameters
109    /// - `prefix`: Per-byte prefix text such as `0x`.
110    ///
111    /// # Returns
112    /// The updated codec.
113    #[inline]
114    pub fn with_byte_prefix(mut self, prefix: impl Into<String>) -> Self {
115        self.config = self.config.with_byte_prefix(prefix);
116        self
117    }
118
119    /// Sets a separator written and accepted between encoded bytes.
120    ///
121    /// # Parameters
122    /// - `separator`: Separator text.
123    ///
124    /// # Returns
125    /// The updated codec.
126    #[inline]
127    pub fn with_separator(mut self, separator: impl Into<String>) -> Self {
128        self.config = self.config.with_separator(separator);
129        self
130    }
131
132    /// Sets whether ASCII whitespace is ignored while decoding.
133    ///
134    /// # Parameters
135    /// - `ignore`: Whether to ignore ASCII whitespace.
136    ///
137    /// # Returns
138    /// The updated codec.
139    #[inline]
140    pub fn with_ignored_ascii_whitespace(mut self, ignore: bool) -> Self {
141        self.config = self.config.with_ignored_ascii_whitespace(ignore);
142        self
143    }
144
145    /// Sets whether ASCII case is ignored when decoding configured prefixes.
146    ///
147    /// This option affects whole-output prefixes and per-byte prefixes during
148    /// decoding only. Encoding writes prefixes exactly as configured.
149    ///
150    /// # Parameters
151    /// - `ignore`: Whether to ignore ASCII case while matching prefixes.
152    ///
153    /// # Returns
154    /// The updated codec.
155    #[inline]
156    pub fn with_ignore_prefix_case(mut self, ignore: bool) -> Self {
157        self.config = self.config.with_ignore_prefix_case(ignore);
158        self
159    }
160
161    /// Encodes bytes into a hexadecimal string.
162    ///
163    /// # Parameters
164    /// - `bytes`: Bytes to encode.
165    ///
166    /// # Returns
167    /// Hexadecimal text.
168    #[inline]
169    #[must_use]
170    pub fn encode(&self, bytes: &[u8]) -> String {
171        let separator_len =
172            self.config.separator.as_ref().map_or(0, String::len);
173        let prefix_len = self.config.prefix.as_ref().map_or(0, String::len);
174        let byte_prefix_len =
175            self.config.byte_prefix.as_ref().map_or(0, String::len);
176        let capacity = prefix_len.saturating_add(
177            bytes
178                .len()
179                .saturating_mul(byte_prefix_len.saturating_add(2))
180                .saturating_add(
181                    bytes.len().saturating_sub(1).saturating_mul(separator_len),
182                ),
183        );
184        let mut output = String::with_capacity(capacity);
185        self.encode_into(bytes, &mut output);
186        output
187    }
188
189    /// Encodes bytes into an existing string.
190    ///
191    /// # Parameters
192    /// - `bytes`: Bytes to encode.
193    /// - `output`: Destination string.
194    #[inline]
195    pub fn encode_into(&self, bytes: &[u8], output: &mut String) {
196        if let Some(prefix) = &self.config.prefix {
197            output.push_str(prefix);
198        }
199        for (index, byte) in bytes.iter().enumerate() {
200            if index > 0
201                && let Some(separator) = &self.config.separator
202            {
203                output.push_str(separator);
204            }
205            if let Some(byte_prefix) = &self.config.byte_prefix {
206                output.push_str(byte_prefix);
207            }
208            push_hex_byte(*byte, self.config.uppercase, output);
209        }
210    }
211
212    /// Decodes hexadecimal text into bytes.
213    ///
214    /// # Parameters
215    /// - `text`: Hexadecimal text.
216    ///
217    /// # Returns
218    /// Decoded bytes.
219    ///
220    /// # Errors
221    /// Returns [`MiscCodecError`] when a configured whole or per-byte prefix is
222    /// missing, when the normalized digit count is odd, or when a non-hex
223    /// digit is found.
224    #[inline]
225    pub fn decode(&self, text: &str) -> MiscCodecResult<Vec<u8>> {
226        self.decode_to_vec(text)
227    }
228
229    /// Decodes hexadecimal text into an existing byte vector.
230    ///
231    /// # Parameters
232    /// - `text`: Hexadecimal text.
233    /// - `output`: Destination byte vector.
234    ///
235    /// # Errors
236    /// Returns [`MiscCodecError`] when the input is malformed.
237    ///
238    /// # TODO
239    /// This method currently decodes into a temporary vector before appending
240    /// so that an error leaves `output` unchanged. A future optimization can
241    /// decode directly into `output` with a checkpoint-and-rollback strategy,
242    /// or validate and reserve in a first pass, to avoid the temporary vector
243    /// while preserving transactional behavior.
244    #[inline]
245    pub fn decode_into(
246        &self,
247        text: &str,
248        output: &mut Vec<u8>,
249    ) -> MiscCodecResult<()> {
250        let decoded = self.decode_to_vec(text)?;
251        output.reserve(decoded.len());
252        output.extend(decoded);
253        Ok(())
254    }
255
256    /// Decodes text into a new byte vector using a single transactional pass.
257    ///
258    /// # Parameters
259    /// - `text`: Text to decode.
260    ///
261    /// # Returns
262    /// Decoded bytes.
263    ///
264    /// # Errors
265    /// Returns [`MiscCodecError::InvalidDigit`] for unsupported characters.
266    #[inline]
267    fn decode_to_vec(&self, text: &str) -> MiscCodecResult<Vec<u8>> {
268        let start_index = self.consume_prefix(text)?;
269        if let Some(separator) = self
270            .config
271            .separator
272            .as_deref()
273            .filter(|separator| !separator.is_empty())
274        {
275            return self.decode_separated(text, start_index, separator);
276        }
277        if let Some(byte_prefix) = self
278            .config
279            .byte_prefix
280            .as_deref()
281            .filter(|prefix| !prefix.is_empty())
282        {
283            return self.decode_byte_prefixed(text, byte_prefix, start_index);
284        }
285        self.decode_unprefixed(text, start_index)
286    }
287
288    /// Consumes the configured whole-output prefix.
289    ///
290    /// # Parameters
291    /// - `text`: Text to decode.
292    ///
293    /// # Returns
294    /// Byte index where byte parsing should start.
295    ///
296    /// # Errors
297    /// Returns [`MiscCodecError::MissingPrefix`] when a non-empty whole-output
298    /// prefix is configured but absent.
299    #[inline]
300    fn consume_prefix(&self, text: &str) -> MiscCodecResult<usize> {
301        let Some(prefix) = self
302            .config
303            .prefix
304            .as_deref()
305            .filter(|prefix| !prefix.is_empty())
306        else {
307            return Ok(0);
308        };
309        let index = if self.starts_with_prefix(text, prefix) {
310            0
311        } else {
312            self.skip_ascii_whitespace(text, 0)
313        };
314        let rest = &text[index..];
315        if self.starts_with_prefix(rest, prefix) {
316            Ok(index + prefix.len())
317        } else {
318            Err(MiscCodecError::MissingPrefix {
319                prefix: prefix.to_owned(),
320            })
321        }
322    }
323
324    /// Decodes separator-delimited input into bytes.
325    ///
326    /// # Parameters
327    /// - `text`: Text to decode.
328    /// - `index`: Byte index where parsing should start.
329    /// - `separator`: Required separator between complete bytes.
330    ///
331    /// # Returns
332    /// Decoded bytes.
333    ///
334    /// # Errors
335    /// Returns [`MiscCodecError`] when a byte is malformed or the configured
336    /// separator is missing between complete bytes.
337    fn decode_separated(
338        &self,
339        text: &str,
340        mut index: usize,
341        separator: &str,
342    ) -> MiscCodecResult<Vec<u8>> {
343        let mut output = Vec::with_capacity(text.len() / 2);
344        index = self.skip_ascii_whitespace(text, index);
345        if index >= text.len() {
346            return Ok(output);
347        }
348        loop {
349            index = self.consume_byte_prefix(text, index)?;
350            let (_, high_char, next_index) =
351                read_required_hex_digit(text, index)?;
352            let (_, low_char, next_index) =
353                read_required_hex_digit(text, next_index)?;
354            output.push(decode_hex_pair(high_char, low_char));
355            index = next_index;
356
357            let separator_index =
358                self.next_separator_index(text, index, separator);
359            if separator_index >= text.len() {
360                return Ok(output);
361            }
362            let rest = &text[separator_index..];
363            if !rest.starts_with(separator) {
364                return Err(invalid_hex_input(&format!(
365                    "missing separator '{separator}' between hex bytes"
366                )));
367            }
368            index = self
369                .skip_ascii_whitespace(text, separator_index + separator.len());
370            if index >= text.len() {
371                return Err(invalid_hex_input(
372                    "separator must be followed by a hex byte",
373                ));
374            }
375        }
376    }
377
378    /// Consumes the configured per-byte prefix.
379    ///
380    /// # Parameters
381    /// - `text`: Text to decode.
382    /// - `index`: Current byte index.
383    ///
384    /// # Returns
385    /// Index after the per-byte prefix, or `index` when no non-empty per-byte
386    /// prefix is configured.
387    ///
388    /// # Errors
389    /// Returns [`MiscCodecError::MissingPrefix`] when the configured per-byte
390    /// prefix is absent.
391    #[inline]
392    fn consume_byte_prefix(
393        &self,
394        text: &str,
395        index: usize,
396    ) -> MiscCodecResult<usize> {
397        let Some(prefix) = self
398            .config
399            .byte_prefix
400            .as_deref()
401            .filter(|prefix| !prefix.is_empty())
402        else {
403            return Ok(index);
404        };
405        let index = if self.starts_with_prefix(&text[index..], prefix) {
406            index
407        } else {
408            self.skip_ignored(text, index)
409        };
410        let rest = &text[index..];
411        if self.starts_with_prefix(rest, prefix) {
412            Ok(index + prefix.len())
413        } else {
414            Err(MiscCodecError::MissingPrefix {
415                prefix: prefix.to_owned(),
416            })
417        }
418    }
419
420    /// Finds the position where the next separator must appear.
421    ///
422    /// # Parameters
423    /// - `text`: Text being decoded.
424    /// - `index`: Current byte index after a complete hex byte.
425    /// - `separator`: Configured separator.
426    ///
427    /// # Returns
428    /// Index where the separator must start, or `text.len()` when only ignored
429    /// trailing whitespace remains.
430    #[inline]
431    fn next_separator_index(
432        &self,
433        text: &str,
434        index: usize,
435        separator: &str,
436    ) -> usize {
437        if !separator.chars().all(|ch| ch.is_ascii_whitespace())
438            && text
439                .get(index..)
440                .is_some_and(|rest| rest.starts_with(separator))
441        {
442            return index;
443        }
444        let whitespace_end = self.skip_ascii_whitespace(text, index);
445        if whitespace_end >= text.len() {
446            return whitespace_end;
447        }
448        if separator.chars().all(|ch| ch.is_ascii_whitespace()) {
449            index
450        } else {
451            whitespace_end
452        }
453    }
454
455    /// Decodes unprefixed input characters into bytes.
456    ///
457    /// # Parameters
458    /// - `text`: Text to decode.
459    ///
460    /// # Returns
461    /// Decoded bytes.
462    ///
463    /// # Errors
464    /// Returns [`MiscCodecError::InvalidDigit`] for unsupported characters.
465    fn decode_unprefixed(
466        &self,
467        text: &str,
468        mut index: usize,
469    ) -> MiscCodecResult<Vec<u8>> {
470        let mut output = Vec::with_capacity(text.len() / 2);
471        let mut pending = None;
472        let mut digit_count = 0usize;
473        while index < text.len() {
474            let rest = &text[index..];
475            let ch = rest.chars().next().expect("index is below text length");
476            if self.config.ignore_ascii_whitespace && ch.is_ascii_whitespace() {
477                index += ch.len_utf8();
478                continue;
479            }
480            if hex_value(ch).is_some() {
481                digit_count += 1;
482                if let Some(high_char) = pending.take() {
483                    output.push(decode_hex_pair(high_char, ch));
484                } else {
485                    pending = Some(ch);
486                }
487                index += ch.len_utf8();
488                continue;
489            }
490            return Err(invalid_hex_digit(index, ch));
491        }
492        if !digit_count.is_multiple_of(2) {
493            return Err(invalid_hex_length(digit_count));
494        }
495        Ok(output)
496    }
497
498    /// Decodes byte-prefixed input characters into bytes.
499    ///
500    /// # Parameters
501    /// - `text`: Text to decode.
502    /// - `prefix`: Required prefix before each byte.
503    /// - `index`: Byte index where parsing should start.
504    ///
505    /// # Returns
506    /// Decoded bytes.
507    ///
508    /// # Errors
509    /// Returns [`MiscCodecError::MissingPrefix`] when a byte prefix is missing,
510    /// or [`MiscCodecError::InvalidDigit`] for unsupported characters.
511    fn decode_byte_prefixed(
512        &self,
513        text: &str,
514        prefix: &str,
515        mut index: usize,
516    ) -> MiscCodecResult<Vec<u8>> {
517        let mut output = Vec::with_capacity(text.len() / 2);
518        let mut digit_count = 0usize;
519        while index < text.len() {
520            index = self.skip_ignored(text, index);
521            if index >= text.len() {
522                break;
523            }
524            let rest = &text[index..];
525            if !self.starts_with_prefix(rest, prefix) {
526                return Err(MiscCodecError::MissingPrefix {
527                    prefix: prefix.to_owned(),
528                });
529            }
530            index += prefix.len();
531
532            let mut pair_count = 0;
533            let mut pair = [(0usize, '\0'); 2];
534            while pair_count < 2 && index < text.len() {
535                let rest = &text[index..];
536                let ch =
537                    rest.chars().next().expect("index is below text length");
538                if self.config.ignore_ascii_whitespace
539                    && ch.is_ascii_whitespace()
540                {
541                    index += ch.len_utf8();
542                    continue;
543                }
544                if hex_value(ch).is_some() {
545                    pair[pair_count] = (index, ch);
546                    index += ch.len_utf8();
547                    pair_count += 1;
548                    digit_count += 1;
549                    continue;
550                }
551                return Err(invalid_hex_digit(index, ch));
552            }
553            if pair_count != 2 {
554                return Err(invalid_hex_input(
555                    "byte prefix must be followed by two hex digits",
556                ));
557            }
558            output.push(decode_hex_pair(pair[0].1, pair[1].1));
559        }
560        if !digit_count.is_multiple_of(2) {
561            return Err(invalid_hex_length(digit_count));
562        }
563        Ok(output)
564    }
565
566    /// Skips ignored ASCII whitespace.
567    ///
568    /// # Parameters
569    /// - `text`: Text being decoded.
570    /// - `index`: Current byte index.
571    ///
572    /// # Returns
573    /// The next byte index that should be parsed.
574    #[inline]
575    fn skip_ignored(&self, text: &str, mut index: usize) -> usize {
576        while index < text.len() {
577            let byte = text.as_bytes()[index];
578            if self.config.ignore_ascii_whitespace && byte.is_ascii_whitespace()
579            {
580                index += 1;
581                continue;
582            }
583            return index;
584        }
585        index
586    }
587
588    /// Skips ignored leading ASCII whitespace.
589    ///
590    /// # Parameters
591    /// - `text`: Text being decoded.
592    /// - `index`: Current byte index.
593    ///
594    /// # Returns
595    /// The next byte index after ignored ASCII whitespace.
596    #[inline]
597    fn skip_ascii_whitespace(&self, text: &str, mut index: usize) -> usize {
598        while self.config.ignore_ascii_whitespace && index < text.len() {
599            if !text.as_bytes()[index].is_ascii_whitespace() {
600                return index;
601            }
602            index += 1;
603        }
604        index
605    }
606
607    /// Tests whether `text` starts with a configured prefix.
608    ///
609    /// # Parameters
610    /// - `text`: Text slice to inspect.
611    /// - `prefix`: Configured prefix.
612    ///
613    /// # Returns
614    /// `true` when `text` starts with `prefix`, honoring the configured
615    /// ASCII case sensitivity for decoding prefixes.
616    #[inline]
617    fn starts_with_prefix(&self, text: &str, prefix: &str) -> bool {
618        if !self.config.ignore_prefix_case {
619            return text.starts_with(prefix);
620        }
621        let Some(candidate) = text.get(..prefix.len()) else {
622            return false;
623        };
624        candidate.eq_ignore_ascii_case(prefix)
625    }
626}
627
628impl Default for HexCodec {
629    /// Creates a lowercase codec without prefix or separators.
630    #[inline]
631    fn default() -> Self {
632        Self::new()
633    }
634}
635
636impl ValueEncoder<[u8]> for HexCodec {
637    type Error = MiscCodecError;
638    type Output = String;
639
640    /// Encodes bytes into hexadecimal text.
641    #[inline]
642    fn encode(&mut self, input: &[u8]) -> Result<Self::Output, Self::Error> {
643        Ok(HexCodec::encode(self, input))
644    }
645}
646
647impl ValueDecoder<str> for HexCodec {
648    type Error = MiscCodecError;
649    type Output = Vec<u8>;
650
651    /// Decodes hexadecimal text into bytes.
652    #[inline]
653    fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
654        HexCodec::decode(self, input)
655    }
656}
657
658/// Converts one hex digit to its value.
659///
660/// # Parameters
661/// - `ch`: Character to inspect.
662///
663/// # Returns
664/// Nibble value, or `None` when `ch` is not a hex digit.
665#[inline(always)]
666pub(crate) fn hex_value(ch: char) -> Option<u8> {
667    match ch {
668        '0'..='9' => Some(ch as u8 - b'0'),
669        'a'..='f' => Some(ch as u8 - b'a' + 10),
670        'A'..='F' => Some(ch as u8 - b'A' + 10),
671        _ => None,
672    }
673}
674
675/// Builds an invalid hexadecimal digit error.
676///
677/// # Parameters
678/// - `index`: Byte index of the invalid character in the original input.
679/// - `character`: Invalid character.
680///
681/// # Returns
682/// A radix-16 digit error.
683pub(crate) fn invalid_hex_digit(
684    index: usize,
685    character: char,
686) -> MiscCodecError {
687    MiscCodecError::InvalidDigit {
688        radix: 16,
689        index,
690        character,
691    }
692}
693
694/// Builds an invalid hexadecimal length error.
695///
696/// # Parameters
697/// - `actual`: Number of normalized hexadecimal digits.
698///
699/// # Returns
700/// An invalid length error describing the even-digit requirement.
701fn invalid_hex_length(actual: usize) -> MiscCodecError {
702    MiscCodecError::InvalidLength {
703        context: "hex digits",
704        expected: "an even number of digits".to_owned(),
705        actual,
706    }
707}
708
709/// Builds an invalid hexadecimal input error.
710///
711/// # Parameters
712/// - `reason`: Human-readable reason the input was rejected.
713///
714/// # Returns
715/// An invalid input error for the hexadecimal codec.
716fn invalid_hex_input(reason: &str) -> MiscCodecError {
717    MiscCodecError::InvalidInput {
718        codec: "hex",
719        reason: reason.to_owned(),
720    }
721}
722
723/// Reads one required hexadecimal digit at a byte boundary.
724///
725/// # Parameters
726/// - `text`: Text being decoded.
727/// - `index`: Byte index where the digit is expected.
728///
729/// # Returns
730/// Original digit index, digit character, and the next byte index.
731///
732/// # Errors
733/// Returns [`MiscCodecError::InvalidInput`] when input ends before the digit,
734/// or [`MiscCodecError::InvalidDigit`] when the next character is not hex.
735#[inline]
736fn read_required_hex_digit(
737    text: &str,
738    index: usize,
739) -> MiscCodecResult<(usize, char, usize)> {
740    let rest = &text[index..];
741    let Some(character) = rest.chars().next() else {
742        return Err(invalid_hex_input("expected a hexadecimal digit"));
743    };
744    if hex_value(character).is_none() {
745        return Err(invalid_hex_digit(index, character));
746    }
747    Ok((index, character, index + character.len_utf8()))
748}
749
750/// Decodes two hexadecimal characters into one byte.
751///
752/// # Parameters
753/// - `high_index`: Original byte index of the high nibble.
754/// - `high_char`: High-nibble character.
755/// - `low_index`: Original byte index of the low nibble.
756/// - `low_char`: Low-nibble character.
757///
758/// # Returns
759/// The decoded byte.
760///
761/// The callers validate both characters before constructing the pair.
762#[inline]
763fn decode_hex_pair(high_char: char, low_char: char) -> u8 {
764    let high = hex_value(high_char).expect("validated hexadecimal digit");
765    let low = hex_value(low_char).expect("validated hexadecimal digit");
766    (high << 4) | low
767}
768
769/// Appends one encoded byte to `output`.
770///
771/// # Parameters
772/// - `byte`: Byte to encode.
773/// - `uppercase`: Whether to use uppercase digits.
774/// - `output`: Destination string.
775#[inline(always)]
776fn push_hex_byte(byte: u8, uppercase: bool, output: &mut String) {
777    output.push(hex_digit(byte >> 4, uppercase));
778    output.push(hex_digit(byte & 0x0f, uppercase));
779}
780
781/// Converts one nibble to a hexadecimal digit.
782///
783/// # Parameters
784/// - `value`: Nibble value.
785/// - `uppercase`: Whether to use uppercase digits.
786///
787/// # Returns
788/// Hexadecimal digit. Values above `0x0f` are masked to their low nibble.
789#[inline(always)]
790pub(crate) fn hex_digit(value: u8, uppercase: bool) -> char {
791    let digits = if uppercase {
792        &UPPER_HEX_DIGITS
793    } else {
794        &LOWER_HEX_DIGITS
795    };
796    digits[(value & 0x0f) as usize]
797}