Skip to main content

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