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