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