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