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