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 =
151            percent_encode_byte(*value, output, output_index, PercentEncodingMode::Rfc3986);
152        debug_assert_eq!(written, required);
153        Ok(required)
154    }
155
156    /// Decodes one value after end of input has been confirmed.
157    ///
158    /// # Safety
159    /// The caller must provide an input index with at least one readable unit
160    /// and a slice satisfying the codec trait's EOF decode preconditions.
161    #[inline]
162    unsafe fn decode_eof(
163        &mut self,
164        input: &[u8],
165        input_index: usize,
166    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>> {
167        debug_assert!(input_index < input.len());
168
169        let (value, consumed) =
170            percent_decode_byte_eof(input, input_index, false).map_err(|error| {
171                map_misc_decode_failure_with_consumed(
172                    error,
173                    percent_invalid_consumed(input, input_index),
174                )
175            })?;
176        debug_assert!(consumed > 0);
177        let consumed = nonzero(consumed);
178        Ok((value, consumed))
179    }
180}
181
182/// Returns the available width of a malformed percent escape.
183///
184/// # Parameters
185/// - `input`: Encoded byte units.
186/// - `index`: Start index of the percent marker.
187///
188/// # Returns
189/// The non-zero number of available units, capped at the three-unit escape
190/// width.
191#[inline(always)]
192pub(crate) fn percent_invalid_consumed(input: &[u8], index: usize) -> NonZeroUsize {
193    NonZeroUsize::new(input.len().saturating_sub(index).clamp(1, 3))
194        .expect("percent decode errors have at least one available unit")
195}
196
197/// Percent-encodes UTF-8 bytes.
198///
199/// # Parameters
200/// - `bytes`: Bytes to encode.
201/// - `mode`: Encoding policy selecting the unescaped set and space handling.
202///
203/// # Returns
204/// Encoded text.
205#[inline]
206pub(crate) fn percent_encode_bytes(bytes: &[u8], mode: PercentEncodingMode) -> String {
207    let capacity = bytes
208        .iter()
209        .map(|byte| percent_encode_len(*byte, mode))
210        .sum();
211    let mut output = String::with_capacity(capacity);
212    for byte in bytes {
213        if mode.is_space_plus(*byte) {
214            output.push('+');
215        } else if mode.is_unescaped(*byte) {
216            output.push(*byte as char);
217        } else {
218            output.push_str(encode_percent_byte(*byte));
219        }
220    }
221    output
222}
223
224/// Percent-decodes UTF-8 bytes.
225///
226/// # Parameters
227/// - `text`: Text to decode.
228/// - `plus_as_space`: Whether `+` should decode to a space byte.
229///
230/// # Returns
231/// Decoded bytes.
232///
233/// # Errors
234/// Returns [`MiscCodecError::InvalidEscape`] for malformed escapes.
235#[inline]
236pub(crate) fn percent_decode_bytes(text: &str, plus_as_space: bool) -> MiscCodecResult<Vec<u8>> {
237    let bytes = text.as_bytes();
238    let mut output = Vec::with_capacity(bytes.len());
239    let mut index = 0;
240    while index < bytes.len() {
241        let (decoded, consumed) = percent_decode_byte(bytes, index, plus_as_space)
242            .map_err(|error| percent_parse_error_to_misc(error, bytes, index))?;
243        output.push(decoded);
244        index += consumed;
245    }
246    Ok(output)
247}
248
249/// Percent-encodes one byte into `output`.
250///
251/// # Parameters
252/// - `byte`: Byte to encode.
253/// - `output`: Destination unit buffer.
254/// - `index`: Start index in `output`.
255/// - `mode`: Encoding policy selecting the unescaped set and space handling.
256///
257/// # Returns
258/// Number of units written.
259#[inline]
260pub(crate) fn percent_encode_byte(
261    byte: u8,
262    output: &mut [u8],
263    index: usize,
264    mode: PercentEncodingMode,
265) -> usize {
266    if mode.is_space_plus(byte) {
267        output[index] = b'+';
268        return 1;
269    }
270    if mode.is_unescaped(byte) {
271        output[index] = byte;
272        return 1;
273    }
274    output[index..index + 3].copy_from_slice(encode_percent_byte(byte).as_bytes());
275    3
276}
277
278/// Returns the encoded width for one byte under `mode`.
279#[inline(always)]
280pub(crate) fn percent_encode_len(byte: u8, mode: PercentEncodingMode) -> usize {
281    if mode.is_space_plus(byte) || mode.is_unescaped(byte) {
282        1
283    } else {
284        3
285    }
286}
287
288/// Decodes one raw byte or `%XX` escape from `input`.
289///
290/// # Parameters
291/// - `input`: Encoded byte units.
292/// - `index`: Start index in `input`.
293/// - `plus_as_space`: Whether `+` should decode to a space byte.
294///
295/// # Returns
296/// Decoded byte and the number of consumed units.
297///
298/// # Errors
299/// Returns [`MiscCodecError::InvalidEscape`] for malformed `%XX` escapes.
300#[inline]
301pub(crate) fn percent_decode_byte(
302    input: &[u8],
303    index: usize,
304    plus_as_space: bool,
305) -> Result<(u8, usize), ParseError> {
306    debug_assert!(index < input.len());
307    let available = input.len() - index;
308    match input[index] {
309        b'%' => {
310            if available < 3 {
311                return Err(ParseError::Incomplete {
312                    required: nonzero(3),
313                });
314            }
315            let high_byte = input[index + 1];
316            let low_byte = input[index + 2];
317            let high =
318                percent_hex_value(high_byte).ok_or_else(|| invalid_percent_escape(input, index))?;
319            let low =
320                percent_hex_value(low_byte).ok_or_else(|| invalid_percent_escape(input, index))?;
321            Ok(((high << 4) | low, 3))
322        }
323        b'+' if plus_as_space => Ok((b' ', 1)),
324        byte => Ok((byte, 1)),
325    }
326}
327
328/// Decodes one percent value using EOF rules.
329#[inline]
330fn percent_decode_byte_eof(
331    input: &[u8],
332    index: usize,
333    plus_as_space: bool,
334) -> Result<(u8, usize), MiscCodecError> {
335    percent_decode_byte(input, index, plus_as_space)
336        .map_err(|error| percent_parse_error_to_misc(error, input, index))
337}
338
339/// Converts an open-stream parse result into a complete-input error.
340#[inline]
341fn percent_parse_error_to_misc(error: ParseError, input: &[u8], index: usize) -> MiscCodecError {
342    match error {
343        ParseError::Incomplete { .. } => invalid_percent_escape(input, index),
344        ParseError::Invalid(error) => error,
345    }
346}
347
348/// Builds a malformed percent escape error.
349///
350/// # Parameters
351/// - `index`: Byte index of the `%` marker in the original input.
352///
353/// # Returns
354/// An invalid escape error for a `%XX` sequence.
355fn invalid_percent_escape(input: &[u8], index: usize) -> MiscCodecError {
356    let end = index.saturating_add(3).min(input.len());
357    MiscCodecError::InvalidEscape {
358        index,
359        escape: String::from_utf8_lossy(&input[index..end]).into_owned(),
360        reason: "expected two hexadecimal digits".to_owned(),
361    }
362}
363
364/// Converts one ASCII hex byte to its nibble value.
365///
366/// # Parameters
367/// - `byte`: ASCII byte to inspect.
368///
369/// # Returns
370/// Nibble value, or `None` when `byte` is not hex.
371#[inline(always)]
372fn percent_hex_value(byte: u8) -> Option<u8> {
373    match byte {
374        b'0'..=b'9' => Some(byte - b'0'),
375        b'a'..=b'f' => Some(byte - b'a' + 10),
376        b'A'..=b'F' => Some(byte - b'A' + 10),
377        _ => None,
378    }
379}