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