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