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///
34/// # Examples
35///
36/// ```
37/// use qubit_codec_misc::FormUrlencodedCodec;
38///
39/// let codec = FormUrlencodedCodec::new();
40/// let encoded = codec.encode("hello world");
41/// assert_eq!(encoded, "hello+world");
42/// assert_eq!(codec.decode(&encoded).unwrap(), "hello world");
43/// ```
44#[must_use]
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub struct FormUrlencodedCodec;
47
48impl FormUrlencodedCodec {
49    /// Creates a form-url-encoded codec.
50    ///
51    /// # Returns
52    /// Form URL encoded codec.
53    #[inline]
54    pub fn new() -> Self {
55        Self
56    }
57
58    /// Encodes text, using `+` for spaces.
59    ///
60    /// # Parameters
61    /// - `text`: Text to encode.
62    ///
63    /// # Returns
64    /// Form-url-encoded text.
65    #[inline]
66    #[must_use]
67    pub fn encode(&self, text: &str) -> String {
68        percent_encode_bytes(text.as_bytes(), PercentEncodingMode::FormUrlencoded)
69    }
70
71    /// Decodes text, treating `+` as space.
72    ///
73    /// # Parameters
74    /// - `text`: Text to decode.
75    ///
76    /// # Returns
77    /// Decoded UTF-8 text.
78    ///
79    /// # Errors
80    /// Returns [`MiscCodecError`] when an escape is malformed or decoded bytes
81    /// are not valid UTF-8.
82    #[inline]
83    pub fn decode(&self, text: &str) -> MiscCodecResult<String> {
84        String::from_utf8(percent_decode_bytes(text, true)?).map_err(MiscCodecError::from)
85    }
86}
87
88impl ValueEncoder<str> for FormUrlencodedCodec {
89    type Error = MiscCodecError;
90    type Output = String;
91
92    /// Encodes text, using `+` for spaces.
93    #[inline]
94    fn encode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
95        Ok(FormUrlencodedCodec::encode(self, input))
96    }
97}
98
99impl ValueDecoder<str> for FormUrlencodedCodec {
100    type Error = MiscCodecError;
101    type Output = String;
102
103    /// Decodes form-url-encoded text.
104    #[inline]
105    fn decode(&mut self, input: &str) -> Result<Self::Output, Self::Error> {
106        FormUrlencodedCodec::decode(self, input)
107    }
108}
109
110impl Codec for FormUrlencodedCodec {
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 form-url-encoded width for one byte.
122    #[inline]
123    fn encode_len(&self, value: &u8) -> usize {
124        percent_encode_len(*value, PercentEncodingMode::FormUrlencoded)
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, true)
141            .map_err(|error| ParseError::into_decode_failure_with_consumed(error, nonzero(3)))?;
142        debug_assert!(consumed > 0);
143        // SAFETY: `percent_decode_byte` returns a non-zero width for every
144        // successful raw byte, `+`, or escape.
145        let consumed = nonzero(consumed);
146        Ok((value, consumed))
147    }
148
149    /// Encodes one byte using form URL encoding.
150    ///
151    /// # Safety
152    /// The caller must provide enough writable output units for
153    /// [`Self::encode_len`].
154    #[inline]
155    unsafe fn encode(
156        &mut self,
157        value: &u8,
158        output: &mut [u8],
159        output_index: usize,
160    ) -> Result<usize, Self::EncodeError> {
161        let required = percent_encode_len(*value, PercentEncodingMode::FormUrlencoded);
162        debug_assert!(output_index + required <= output.len());
163
164        let written = percent_encode_byte(*value, output, output_index, PercentEncodingMode::FormUrlencoded);
165        debug_assert_eq!(written, required);
166        Ok(required)
167    }
168
169    /// Decodes one value after end of input has been confirmed.
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 EOF decode preconditions.
174    #[inline]
175    unsafe fn decode_eof(
176        &mut self,
177        input: &[u8],
178        input_index: usize,
179    ) -> Result<(u8, core::num::NonZeroUsize), DecodeFailure<Self::DecodeError>> {
180        debug_assert!(input_index < input.len());
181
182        let (value, consumed) = percent_decode_byte(input, input_index, true).map_err(|error| match error {
183            ParseError::Incomplete { .. } => map_misc_decode_failure_with_consumed(
184                MiscCodecError::InvalidEscape {
185                    index: input_index,
186                    escape: String::from_utf8_lossy(&input[input_index..]).into_owned(),
187                    reason: "expected two hexadecimal digits".to_owned(),
188                },
189                percent_invalid_consumed(input, input_index),
190            ),
191            ParseError::Invalid(error) => {
192                map_misc_decode_failure_with_consumed(error, percent_invalid_consumed(input, input_index))
193            }
194        })?;
195        debug_assert!(consumed > 0);
196        let consumed = nonzero(consumed);
197        Ok((value, consumed))
198    }
199}