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