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}