Skip to main content

qubit_codec_misc/
percent_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//! Percent text codec.
9
10use core::num::NonZeroUsize;
11
12use percent_encoding::percent_encode_byte as encode_percent_byte;
13use qubit_codec::Codec;
14use qubit_codec::DecodeFailure;
15use qubit_codec::ValueDecoder;
16use qubit_codec::ValueEncoder;
17use qubit_utils::nonzero;
18
19use crate::MiscCodecError;
20use crate::MiscCodecResult;
21use crate::internal::ParseError;
22use crate::internal::PercentEncodingMode;
23use crate::misc_codec_error::map_misc_decode_failure_with_consumed;
24
25/// Encodes and decodes percent-encoded UTF-8 text.
26///
27/// Its low-level [`Codec<Value = u8, Unit = u8>`] implementation converts one
28/// byte to either one unreserved ASCII unit or a `%XX` escape. UTF-8 validation
29/// remains part of the owned [`decode`](Self::decode) helper.
30#[must_use]
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
32pub struct PercentCodec;
33
34impl PercentCodec {
35    /// Creates a percent codec.
36    ///
37    /// # Returns
38    /// Percent codec.
39    #[inline]
40    pub fn new() -> Self {
41        Self
42    }
43
44    /// Encodes text using percent encoding.
45    ///
46    /// # Parameters
47    /// - `text`: UTF-8 text to encode.
48    ///
49    /// # Returns
50    /// Percent-encoded text.
51    #[inline]
52    #[must_use]
53    pub fn encode(&self, text: &str) -> String {
54        percent_encode_bytes(text.as_bytes(), PercentEncodingMode::Rfc3986)
55    }
56
57    /// Decodes percent-encoded UTF-8 text.
58    ///
59    /// # Parameters
60    /// - `text`: Percent-encoded text.
61    ///
62    /// # Returns
63    /// Decoded UTF-8 text.
64    ///
65    /// # Errors
66    /// Returns [`MiscCodecError`] when a percent escape is malformed or decoded
67    /// bytes are not valid UTF-8.
68    #[inline]
69    pub fn decode(&self, text: &str) -> MiscCodecResult<String> {
70        String::from_utf8(percent_decode_bytes(text, false)?).map_err(MiscCodecError::from)
71    }
72}
73
74impl ValueEncoder<str> for PercentCodec {
75    type Error = MiscCodecError;
76    type Output = String;
77
78    /// Encodes text using percent encoding.
79    #[inline]
80    fn encode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
81        Ok(PercentCodec::encode(self, input))
82    }
83}
84
85impl ValueDecoder<str> for PercentCodec {
86    type Error = MiscCodecError;
87    type Output = String;
88
89    /// Decodes percent-encoded text.
90    #[inline]
91    fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
92        PercentCodec::decode(self, input)
93    }
94}
95
96impl Codec for PercentCodec {
97    type Value = u8;
98    type Unit = u8;
99    type DecodeError = MiscCodecError;
100    type EncodeError = MiscCodecError;
101
102    const MIN_UNITS_PER_VALUE: usize = 1;
103    const MAX_ENCODE_UNITS_PER_VALUE: usize = 3;
104
105    const MAX_DECODE_UNITS_PER_VALUE: usize = 3;
106
107    /// Returns the exact percent-encoded width for one byte.
108    #[inline(always)]
109    fn encode_len(&self, value: &u8) -> usize {
110        percent_encode_len(*value, PercentEncodingMode::Rfc3986)
111    }
112
113    /// Decodes one raw byte or `%XX` escape.
114    ///
115    /// # Safety
116    /// The caller must provide an input index with at least one readable unit
117    /// and a slice satisfying the codec trait's decode preconditions.
118    #[inline]
119    unsafe fn decode(
120        &mut self,
121        input: &[u8],
122        input_index: usize,
123    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>> {
124        debug_assert!(input_index < input.len());
125
126        let (value, consumed) = percent_decode_byte(input, input_index, false)
127            .map_err(|error| ParseError::into_decode_failure_with_consumed(error, nonzero(3)))?;
128        debug_assert!(consumed > 0);
129        // SAFETY: `percent_decode_byte` returns a non-zero width for every
130        // successful raw byte or escape.
131        let consumed = nonzero(consumed);
132        Ok((value, consumed))
133    }
134
135    /// Encodes one byte using percent encoding.
136    ///
137    /// # Safety
138    /// The caller must provide enough writable output units for
139    /// [`Self::encode_len`].
140    #[inline]
141    unsafe fn encode(
142        &mut self,
143        value: &u8,
144        output: &mut [u8],
145        output_index: usize,
146    ) -> Result<usize, Self::EncodeError> {
147        let required = percent_encode_len(*value, PercentEncodingMode::Rfc3986);
148        debug_assert!(output_index + required <= output.len());
149
150        let written = percent_encode_byte(*value, output, output_index, PercentEncodingMode::Rfc3986);
151        debug_assert_eq!(written, required);
152        Ok(required)
153    }
154
155    /// Decodes one value after end of input has been confirmed.
156    ///
157    /// # Safety
158    /// The caller must provide an input index with at least one readable unit
159    /// and a slice satisfying the codec trait's EOF decode preconditions.
160    #[inline]
161    unsafe fn decode_eof(
162        &mut self,
163        input: &[u8],
164        input_index: usize,
165    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>> {
166        debug_assert!(input_index < input.len());
167
168        let (value, consumed) = percent_decode_byte_eof(input, input_index, false).map_err(|error| {
169            map_misc_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
170        })?;
171        debug_assert!(consumed > 0);
172        let consumed = nonzero(consumed);
173        Ok((value, consumed))
174    }
175}
176
177/// Returns the available width of a malformed percent escape.
178///
179/// # Parameters
180/// - `input`: Encoded byte units.
181/// - `index`: Start index of the percent marker.
182///
183/// # Returns
184/// The non-zero number of available units, capped at the three-unit escape
185/// width.
186#[inline(always)]
187pub(crate) fn percent_invalid_consumed(input: &[u8], index: usize) -> NonZeroUsize {
188    NonZeroUsize::new(input.len().saturating_sub(index).clamp(1, 3))
189        .expect("percent decode errors have at least one available unit")
190}
191
192/// Percent-encodes UTF-8 bytes.
193///
194/// # Parameters
195/// - `bytes`: Bytes to encode.
196/// - `mode`: Encoding policy selecting the unescaped set and space handling.
197///
198/// # Returns
199/// Encoded text.
200#[inline]
201pub(crate) fn percent_encode_bytes(bytes: &[u8], mode: PercentEncodingMode) -> String {
202    let capacity = bytes.iter().map(|byte| percent_encode_len(*byte, mode)).sum();
203    let mut output = String::with_capacity(capacity);
204    for byte in bytes {
205        if mode.is_space_plus(*byte) {
206            output.push('+');
207        } else if mode.is_unescaped(*byte) {
208            output.push(*byte as char);
209        } else {
210            output.push_str(encode_percent_byte(*byte));
211        }
212    }
213    output
214}
215
216/// Percent-decodes UTF-8 bytes.
217///
218/// # Parameters
219/// - `text`: Text to decode.
220/// - `plus_as_space`: Whether `+` should decode to a space byte.
221///
222/// # Returns
223/// Decoded bytes.
224///
225/// # Errors
226/// Returns [`MiscCodecError::InvalidEscape`] for malformed escapes.
227#[inline]
228pub(crate) fn percent_decode_bytes(text: &str, plus_as_space: bool) -> MiscCodecResult<Vec<u8>> {
229    let bytes = text.as_bytes();
230    let mut output = Vec::with_capacity(bytes.len());
231    let mut index = 0;
232    while index < bytes.len() {
233        let (decoded, consumed) = percent_decode_byte(bytes, index, plus_as_space)
234            .map_err(|error| percent_parse_error_to_misc(error, bytes, index))?;
235        output.push(decoded);
236        index += consumed;
237    }
238    Ok(output)
239}
240
241/// Percent-encodes one byte into `output`.
242///
243/// # Parameters
244/// - `byte`: Byte to encode.
245/// - `output`: Destination unit buffer.
246/// - `index`: Start index in `output`.
247/// - `mode`: Encoding policy selecting the unescaped set and space handling.
248///
249/// # Returns
250/// Number of units written.
251#[inline]
252pub(crate) fn percent_encode_byte(byte: u8, output: &mut [u8], index: usize, mode: PercentEncodingMode) -> usize {
253    if mode.is_space_plus(byte) {
254        output[index] = b'+';
255        return 1;
256    }
257    if mode.is_unescaped(byte) {
258        output[index] = byte;
259        return 1;
260    }
261    output[index..index + 3].copy_from_slice(encode_percent_byte(byte).as_bytes());
262    3
263}
264
265/// Returns the encoded width for one byte under `mode`.
266#[inline(always)]
267pub(crate) fn percent_encode_len(byte: u8, mode: PercentEncodingMode) -> usize {
268    if mode.is_space_plus(byte) || mode.is_unescaped(byte) {
269        1
270    } else {
271        3
272    }
273}
274
275/// Decodes one raw byte or `%XX` escape from `input`.
276///
277/// # Parameters
278/// - `input`: Encoded byte units.
279/// - `index`: Start index in `input`.
280/// - `plus_as_space`: Whether `+` should decode to a space byte.
281///
282/// # Returns
283/// Decoded byte and the number of consumed units.
284///
285/// # Errors
286/// Returns [`MiscCodecError::InvalidEscape`] for malformed `%XX` escapes.
287#[inline]
288pub(crate) fn percent_decode_byte(input: &[u8], index: usize, plus_as_space: bool) -> Result<(u8, usize), ParseError> {
289    debug_assert!(index < input.len());
290    let available = input.len() - index;
291    match input[index] {
292        b'%' => {
293            if available < 3 {
294                return Err(ParseError::Incomplete { required: nonzero(3) });
295            }
296            let high_byte = input[index + 1];
297            let low_byte = input[index + 2];
298            let high = percent_hex_value(high_byte).ok_or_else(|| invalid_percent_escape(input, index))?;
299            let low = percent_hex_value(low_byte).ok_or_else(|| invalid_percent_escape(input, index))?;
300            Ok(((high << 4) | low, 3))
301        }
302        b'+' if plus_as_space => Ok((b' ', 1)),
303        byte => Ok((byte, 1)),
304    }
305}
306
307/// Decodes one percent value using EOF rules.
308#[inline]
309fn percent_decode_byte_eof(input: &[u8], index: usize, plus_as_space: bool) -> Result<(u8, usize), MiscCodecError> {
310    percent_decode_byte(input, index, plus_as_space).map_err(|error| percent_parse_error_to_misc(error, input, index))
311}
312
313/// Converts an open-stream parse result into a complete-input error.
314#[inline]
315fn percent_parse_error_to_misc(error: ParseError, input: &[u8], index: usize) -> MiscCodecError {
316    match error {
317        ParseError::Incomplete { .. } => invalid_percent_escape(input, index),
318        ParseError::Invalid(error) => error,
319    }
320}
321
322/// Builds a malformed percent escape error.
323///
324/// # Parameters
325/// - `index`: Byte index of the `%` marker in the original input.
326///
327/// # Returns
328/// An invalid escape error for a `%XX` sequence.
329fn invalid_percent_escape(input: &[u8], index: usize) -> MiscCodecError {
330    let end = index.saturating_add(3).min(input.len());
331    MiscCodecError::InvalidEscape {
332        index,
333        escape: String::from_utf8_lossy(&input[index..end]).into_owned(),
334        reason: "expected two hexadecimal digits".to_owned(),
335    }
336}
337
338/// Converts one ASCII hex byte to its nibble value.
339///
340/// # Parameters
341/// - `byte`: ASCII byte to inspect.
342///
343/// # Returns
344/// Nibble value, or `None` when `byte` is not hex.
345#[inline(always)]
346fn percent_hex_value(byte: u8) -> Option<u8> {
347    match byte {
348        b'0'..=b'9' => Some(byte - b'0'),
349        b'a'..=b'f' => Some(byte - b'a' + 10),
350        b'A'..=b'F' => Some(byte - b'A' + 10),
351        _ => None,
352    }
353}