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