pub fn encode(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char)
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
pub fn decode(input: &str) -> String {
let bytes = input.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'+' => {
out.push(b' ');
index += 1;
}
b'%' if index + 2 < bytes.len() => {
match u8::from_str_radix(&input[index + 1..index + 3], 16) {
Ok(byte) => {
out.push(byte);
index += 3;
}
Err(_) => {
out.push(b'%');
index += 1;
}
}
}
byte => {
out.push(byte);
index += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
pub fn form_encode(pairs: &[(&str, &str)]) -> String {
pairs
.iter()
.map(|(key, value)| format!("{}={}", encode(key), encode(value)))
.collect::<Vec<_>>()
.join("&")
}
pub fn form_decode(input: &str) -> Vec<(String, String)> {
input
.trim_start_matches('?')
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| match pair.split_once('=') {
Some((key, value)) => (decode(key), decode(value)),
None => (decode(pair), String::new()),
})
.collect()
}
pub fn append_query(url: &str, query: &str) -> String {
if query.is_empty() {
return url.to_string();
}
let separator = if url.contains('?') { '&' } else { '?' };
format!("{url}{separator}{query}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encodes_everything_outside_the_unreserved_set() {
assert_eq!(encode("abcXYZ019-._~"), "abcXYZ019-._~");
assert_eq!(encode("a b"), "a%20b");
assert_eq!(encode("a+b/c=d"), "a%2Bb%2Fc%3Dd");
assert_eq!(encode("https://x.test/cb?a=1"), "https%3A%2F%2Fx.test%2Fcb%3Fa%3D1");
}
#[test]
fn a_plus_in_a_secret_survives_the_round_trip() {
let secret = "s3cr+t/val=ue";
assert_eq!(decode(&encode(secret)), secret);
}
#[test]
fn decoding_treats_plus_as_a_space_the_way_form_bodies_do() {
assert_eq!(decode("a+b"), "a b");
assert_eq!(decode("a%20b"), "a b");
}
#[test]
fn a_malformed_escape_is_kept_rather_than_dropped() {
assert_eq!(decode("100%"), "100%");
assert_eq!(decode("100%zz"), "100%zz");
}
#[test]
fn handles_non_ascii() {
assert_eq!(encode("ä"), "%C3%A4");
assert_eq!(decode("%C3%A4"), "ä");
}
#[test]
fn form_bodies_round_trip() {
let body = form_encode(&[("grant_type", "authorization_code"), ("code", "a b&c")]);
assert_eq!(body, "grant_type=authorization_code&code=a%20b%26c");
let parsed = form_decode(&body);
assert_eq!(parsed[0], ("grant_type".to_string(), "authorization_code".to_string()));
assert_eq!(parsed[1], ("code".to_string(), "a b&c".to_string()));
}
#[test]
fn a_repeated_parameter_is_visible_rather_than_collapsed() {
let parsed = form_decode("code=a&code=b");
assert_eq!(parsed.len(), 2);
}
#[test]
fn a_valueless_parameter_parses() {
assert_eq!(form_decode("prompt"), vec![("prompt".to_string(), String::new())]);
}
#[test]
fn query_is_appended_with_the_right_separator() {
assert_eq!(append_query("https://x.test/a", "b=1"), "https://x.test/a?b=1");
assert_eq!(append_query("https://x.test/a?z=0", "b=1"), "https://x.test/a?z=0&b=1");
assert_eq!(append_query("https://x.test/a", ""), "https://x.test/a");
}
}