qubit_codec_misc/percent/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 codec type and trait implementations.
9
10use core::num::NonZeroUsize;
11
12use qubit_codec::Codec;
13use qubit_codec::DecodeFailure;
14use qubit_codec::ValueDecoder;
15use qubit_codec::ValueEncoder;
16use qubit_utils::nonzero;
17
18use super::internal::percent_decode_byte_eof;
19use super::percent_decode_byte;
20use super::percent_decode_bytes;
21use super::percent_encode_byte;
22use super::percent_encode_bytes;
23use super::percent_encode_len;
24use super::percent_invalid_consumed;
25use crate::MiscCodecError;
26use crate::MiscCodecResult;
27use crate::internal::ParseError;
28use crate::internal::PercentEncodingMode;
29use crate::misc_error::map_misc_decode_failure_with_consumed;
30
31/// Encodes and decodes percent-encoded UTF-8 text.
32///
33/// Its low-level [`Codec<Value = u8, Unit = u8>`] implementation converts one
34/// byte to either one unreserved ASCII unit or a `%XX` escape. UTF-8 validation
35/// remains part of the owned [`decode`](Self::decode) helper.
36/// Streaming skip/replace hooks may consume the malformed span visible in the
37/// current input chunk, so recovery output for malformed input can depend on
38/// chunk boundaries. This does not affect valid input or strict decoding.
39///
40/// # Examples
41///
42/// ```
43/// use qubit_codec_misc::PercentCodec;
44///
45/// let codec = PercentCodec::new();
46/// let encoded = codec.encode("hello world");
47/// assert_eq!(encoded, "hello%20world");
48/// assert_eq!(codec.decode(&encoded).expect("valid encoded text"), "hello world");
49/// ```
50#[must_use]
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub struct PercentCodec;
53
54impl PercentCodec {
55 /// Creates a percent codec.
56 ///
57 /// # Returns
58 /// Percent codec.
59 #[inline]
60 pub fn new() -> Self {
61 Self
62 }
63
64 /// Encodes text using percent encoding.
65 ///
66 /// # Parameters
67 /// - `text`: UTF-8 text to encode.
68 ///
69 /// # Returns
70 /// Percent-encoded text.
71 #[inline]
72 #[must_use]
73 pub fn encode(&self, text: &str) -> String {
74 percent_encode_bytes(text.as_bytes(), PercentEncodingMode::Rfc3986)
75 }
76
77 /// Decodes percent-encoded UTF-8 text.
78 ///
79 /// # Parameters
80 /// - `text`: Percent-encoded text.
81 ///
82 /// # Returns
83 /// Decoded UTF-8 text.
84 ///
85 /// # Errors
86 /// Returns [`MiscCodecError`] when a percent escape is malformed or decoded
87 /// bytes are not valid UTF-8.
88 #[inline]
89 pub fn decode(&self, text: &str) -> MiscCodecResult<String> {
90 String::from_utf8(percent_decode_bytes(text, false)?).map_err(MiscCodecError::from)
91 }
92}
93
94impl ValueEncoder<str> for PercentCodec {
95 type Error = MiscCodecError;
96 type Output = String;
97
98 /// Encodes one text input using RFC 3986 percent encoding.
99 ///
100 /// # Parameters
101 /// - `input`: UTF-8 text to encode.
102 ///
103 /// # Returns
104 /// The percent-encoded text.
105 ///
106 /// # Errors
107 /// This implementation does not reject text and therefore always succeeds.
108 #[inline]
109 fn encode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
110 Ok(PercentCodec::encode(self, input))
111 }
112}
113
114impl ValueDecoder<str> for PercentCodec {
115 type Error = MiscCodecError;
116 type Output = String;
117
118 /// Decodes one percent-encoded UTF-8 text input.
119 ///
120 /// # Parameters
121 /// - `input`: Percent-encoded text to decode.
122 ///
123 /// # Returns
124 /// The decoded UTF-8 text.
125 ///
126 /// # Errors
127 /// Returns an error for malformed escapes or decoded bytes that are not
128 /// valid UTF-8.
129 #[inline]
130 fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
131 PercentCodec::decode(self, input)
132 }
133}
134
135impl Codec for PercentCodec {
136 type Value = u8;
137 type Unit = u8;
138 type DecodeError = MiscCodecError;
139 type EncodeError = MiscCodecError;
140
141 const MIN_UNITS_PER_VALUE: usize = 1;
142 const MAX_ENCODE_UNITS_PER_VALUE: usize = 3;
143
144 const MAX_DECODE_UNITS_PER_VALUE: usize = 3;
145
146 /// Returns the exact RFC 3986 encoded width for one byte.
147 ///
148 /// # Parameters
149 /// - `value`: Byte whose encoded width is measured.
150 ///
151 /// # Returns
152 /// One for an unreserved byte, or three for an escaped byte.
153 #[inline]
154 fn encode_len(&self, value: &u8) -> usize {
155 percent_encode_len(*value, PercentEncodingMode::Rfc3986)
156 }
157
158 /// Decodes one raw byte or `%XX` escape.
159 ///
160 /// # Parameters
161 /// - `input`: Encoded byte units.
162 /// - `input_index`: Index of the byte or escape to decode.
163 ///
164 /// # Returns
165 /// The decoded byte and non-zero number of consumed units.
166 ///
167 /// # Errors
168 /// Returns a decode failure when the visible escape is malformed or
169 /// incomplete.
170 ///
171 /// # Safety
172 /// The caller must provide an input index with at least one readable unit
173 /// and a slice satisfying the codec trait's decode preconditions.
174 #[inline]
175 unsafe fn decode(
176 &mut self,
177 input: &[u8],
178 input_index: usize,
179 ) -> Result<(u8, NonZeroUsize), DecodeFailure<Self::DecodeError>> {
180 debug_assert!(input_index < input.len());
181
182 let (value, consumed) = percent_decode_byte(input, input_index, false).map_err(|error| {
183 ParseError::into_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
184 })?;
185 debug_assert!(consumed > 0);
186 // SAFETY: `percent_decode_byte` returns a non-zero width for every
187 // successful raw byte or escape.
188 let consumed = nonzero(consumed);
189 Ok((value, consumed))
190 }
191
192 /// Encodes one byte using percent encoding.
193 ///
194 /// # Parameters
195 /// - `value`: Byte to encode.
196 /// - `output`: Destination unit buffer.
197 /// - `output_index`: Start index in `output`.
198 ///
199 /// # Returns
200 /// Number of units written.
201 ///
202 /// # Errors
203 /// This implementation accepts every byte and therefore always succeeds.
204 ///
205 /// # Safety
206 /// The caller must provide enough writable output units for
207 /// [`Self::encode_len`].
208 #[inline]
209 unsafe fn encode(
210 &mut self,
211 value: &u8,
212 output: &mut [u8],
213 output_index: usize,
214 ) -> Result<usize, Self::EncodeError> {
215 let required = percent_encode_len(*value, PercentEncodingMode::Rfc3986);
216 debug_assert!(output_index + required <= output.len());
217
218 let written = percent_encode_byte(*value, output, output_index, PercentEncodingMode::Rfc3986);
219 debug_assert_eq!(written, required);
220 Ok(required)
221 }
222
223 /// Decodes one value after end of input has been confirmed.
224 ///
225 /// # Parameters
226 /// - `input`: Complete encoded byte units.
227 /// - `input_index`: Index of the byte or escape to decode.
228 ///
229 /// # Returns
230 /// The decoded byte and non-zero number of consumed units.
231 ///
232 /// # Errors
233 /// Returns a decode failure when the escape is malformed or incomplete.
234 ///
235 /// # Safety
236 /// The caller must provide an input index with at least one readable unit
237 /// and a slice satisfying the codec trait's EOF decode preconditions.
238 #[inline]
239 unsafe fn decode_eof(
240 &mut self,
241 input: &[u8],
242 input_index: usize,
243 ) -> Result<(u8, NonZeroUsize), DecodeFailure<Self::DecodeError>> {
244 debug_assert!(input_index < input.len());
245
246 let (value, consumed) = percent_decode_byte_eof(input, input_index, false).map_err(|error| {
247 map_misc_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
248 })?;
249 debug_assert!(consumed > 0);
250 let consumed = nonzero(consumed);
251 Ok((value, consumed))
252 }
253}