Skip to main content

qubit_codec_misc/
form_urlencoded_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//! `application/x-www-form-urlencoded` text codec.
9
10use qubit_codec::Codec;
11use qubit_codec::DecodeFailure;
12use qubit_codec::ValueDecoder;
13use qubit_codec::ValueEncoder;
14use qubit_utils::nonzero;
15
16use crate::MiscCodecError;
17use crate::MiscCodecResult;
18use crate::internal::ParseError;
19use crate::internal::PercentEncodingMode;
20use crate::misc_codec_error::map_misc_decode_failure_with_consumed;
21use crate::percent_codec::percent_decode_byte;
22use crate::percent_codec::percent_decode_bytes;
23use crate::percent_codec::percent_encode_byte;
24use crate::percent_codec::percent_encode_bytes;
25use crate::percent_codec::percent_encode_len;
26use crate::percent_codec::percent_invalid_consumed;
27
28/// Encodes and decodes `application/x-www-form-urlencoded` text fragments.
29///
30/// Its low-level [`Codec<Value = u8, Unit = u8>`] implementation converts one
31/// byte at a time, including the form-specific space and `+` mapping. UTF-8
32/// validation remains part of the owned [`decode`](Self::decode) helper.
33/// Streaming skip/replace hooks may consume the malformed span visible in the
34/// current input chunk, so recovery output for malformed input can depend on
35/// chunk boundaries. This does not affect valid input or strict decoding.
36///
37/// # Examples
38///
39/// ```
40/// use qubit_codec_misc::FormUrlencodedCodec;
41///
42/// let codec = FormUrlencodedCodec::new();
43/// let encoded = codec.encode("hello world");
44/// assert_eq!(encoded, "hello+world");
45/// assert_eq!(codec.decode(&encoded).unwrap(), "hello world");
46/// ```
47#[must_use]
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub struct FormUrlencodedCodec;
50
51impl FormUrlencodedCodec {
52    /// Creates a form-url-encoded codec.
53    ///
54    /// # Returns
55    /// Form URL encoded codec.
56    #[inline]
57    pub fn new() -> Self {
58        Self
59    }
60
61    /// Encodes text, using `+` for spaces.
62    ///
63    /// # Parameters
64    /// - `text`: Text to encode.
65    ///
66    /// # Returns
67    /// Form-url-encoded text.
68    #[inline]
69    #[must_use]
70    pub fn encode(&self, text: &str) -> String {
71        percent_encode_bytes(text.as_bytes(), PercentEncodingMode::FormUrlencoded)
72    }
73
74    /// Decodes text, treating `+` as space.
75    ///
76    /// # Parameters
77    /// - `text`: Text to decode.
78    ///
79    /// # Returns
80    /// Decoded UTF-8 text.
81    ///
82    /// # Errors
83    /// Returns [`MiscCodecError`] when an escape is malformed or decoded bytes
84    /// are not valid UTF-8.
85    #[inline]
86    pub fn decode(&self, text: &str) -> MiscCodecResult<String> {
87        String::from_utf8(percent_decode_bytes(text, true)?).map_err(MiscCodecError::from)
88    }
89}
90
91impl ValueEncoder<str> for FormUrlencodedCodec {
92    type Error = MiscCodecError;
93    type Output = String;
94
95    /// Encodes text, using `+` for spaces.
96    #[inline]
97    fn encode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
98        Ok(FormUrlencodedCodec::encode(self, input))
99    }
100}
101
102impl ValueDecoder<str> for FormUrlencodedCodec {
103    type Error = MiscCodecError;
104    type Output = String;
105
106    /// Decodes form-url-encoded text.
107    #[inline]
108    fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
109        FormUrlencodedCodec::decode(self, input)
110    }
111}
112
113impl Codec for FormUrlencodedCodec {
114    type Value = u8;
115    type Unit = u8;
116    type DecodeError = MiscCodecError;
117    type EncodeError = MiscCodecError;
118
119    const MIN_UNITS_PER_VALUE: usize = 1;
120    const MAX_ENCODE_UNITS_PER_VALUE: usize = 3;
121
122    const MAX_DECODE_UNITS_PER_VALUE: usize = 3;
123
124    /// Returns the exact form-url-encoded width for one byte.
125    #[inline]
126    fn encode_len(&self, value: &u8) -> usize {
127        percent_encode_len(*value, PercentEncodingMode::FormUrlencoded)
128    }
129
130    /// Decodes one raw byte, `+`, or `%XX` escape.
131    ///
132    /// # Safety
133    /// The caller must provide an input index with at least one readable unit
134    /// and a slice satisfying the codec trait's decode preconditions.
135    #[inline]
136    unsafe fn decode(
137        &mut self,
138        input: &[u8],
139        input_index: usize,
140    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>> {
141        debug_assert!(input_index < input.len());
142
143        let (value, consumed) = percent_decode_byte(input, input_index, true).map_err(|error| {
144            ParseError::into_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
145        })?;
146        debug_assert!(consumed > 0);
147        // SAFETY: `percent_decode_byte` returns a non-zero width for every
148        // successful raw byte, `+`, or escape.
149        let consumed = nonzero(consumed);
150        Ok((value, consumed))
151    }
152
153    /// Encodes one byte using form URL encoding.
154    ///
155    /// # Safety
156    /// The caller must provide enough writable output units for
157    /// [`Self::encode_len`].
158    #[inline]
159    unsafe fn encode(
160        &mut self,
161        value: &u8,
162        output: &mut [u8],
163        output_index: usize,
164    ) -> Result<usize, Self::EncodeError> {
165        let required = percent_encode_len(*value, PercentEncodingMode::FormUrlencoded);
166        debug_assert!(output_index + required <= output.len());
167
168        let written = percent_encode_byte(*value, output, output_index, PercentEncodingMode::FormUrlencoded);
169        debug_assert_eq!(written, required);
170        Ok(required)
171    }
172
173    /// Decodes one value after end of input has been confirmed.
174    ///
175    /// # Safety
176    /// The caller must provide an input index with at least one readable unit
177    /// and a slice satisfying the codec trait's EOF decode preconditions.
178    #[inline]
179    unsafe fn decode_eof(
180        &mut self,
181        input: &[u8],
182        input_index: usize,
183    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>> {
184        debug_assert!(input_index < input.len());
185
186        let (value, consumed) = percent_decode_byte(input, input_index, true).map_err(|error| match error {
187            ParseError::Incomplete { .. } => map_misc_decode_failure_with_consumed(
188                MiscCodecError::InvalidEscape {
189                    index: input_index,
190                    escape: String::from_utf8_lossy(&input[input_index..]).into_owned(),
191                    reason: "expected two hexadecimal digits".to_owned(),
192                },
193                percent_invalid_consumed(input, input_index),
194            ),
195            ParseError::Invalid(error) => {
196                map_misc_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
197            }
198        })?;
199        debug_assert!(consumed > 0);
200        let consumed = nonzero(consumed);
201        Ok((value, consumed))
202    }
203}