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