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