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