const ALWAYS_SAFE: &[u8] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-~";
const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
fn safe_table(safe: &str) -> [bool; 128] {
let mut table = [false; 128];
for &b in ALWAYS_SAFE {
table[b as usize] = true;
}
for b in safe.bytes() {
if b < 128 {
table[b as usize] = true;
}
}
table
}
pub fn quote_from_bytes(bytes: &[u8], safe: &str) -> String {
let table = safe_table(safe);
let mut out = String::with_capacity(bytes.len());
for &b in bytes {
if b < 128 && table[b as usize] {
out.push(b as char);
} else {
out.push('%');
out.push(HEX_UPPER[(b >> 4) as usize] as char);
out.push(HEX_UPPER[(b & 0x0f) as usize] as char);
}
}
out
}
pub fn quote(string: &str, safe: &str) -> String {
quote_from_bytes(string.as_bytes(), safe)
}
pub fn quote_plus(string: &str, safe: &str) -> String {
if !string.contains(' ') {
return quote(string, safe);
}
let mut safe_with_space = String::with_capacity(safe.len() + 1);
safe_with_space.push_str(safe);
safe_with_space.push(' ');
quote(string, &safe_with_space).replace(' ', "+")
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn unquote_impl(bytes: &[u8]) -> Vec<u8> {
let mut parts = bytes.split(|&b| b == b'%');
let first = parts.next().unwrap_or(&[]);
let mut res = first.to_vec();
for item in parts {
if item.len() >= 2
&& let (Some(h), Some(l)) = (hex_val(item[0]), hex_val(item[1]))
{
res.push((h << 4) | l);
res.extend_from_slice(&item[2..]);
continue;
}
res.push(b'%');
res.extend_from_slice(item);
}
res
}
pub fn unquote_to_bytes(string: &str) -> Vec<u8> {
unquote_impl(string.as_bytes())
}
pub fn unquote(string: &str) -> String {
if !string.contains('%') {
return string.to_string();
}
let mut out = String::with_capacity(string.len());
let mut ascii_run = String::new();
let flush = |run: &mut String, out: &mut String| {
if !run.is_empty() {
out.push_str(&String::from_utf8_lossy(&unquote_impl(run.as_bytes())));
run.clear();
}
};
for ch in string.chars() {
if (ch as u32) <= 0x7f {
ascii_run.push(ch);
} else {
flush(&mut ascii_run, &mut out);
out.push(ch);
}
}
flush(&mut ascii_run, &mut out);
out
}
pub fn unquote_plus(string: &str) -> String {
unquote(&string.replace('+', " "))
}
const USES_NETLOC: &[&str] = &[
"", "ftp", "http", "gopher", "nntp", "telnet", "imap", "wais", "file", "mms", "https",
"shttp", "snews", "prospero", "rtsp", "rtsps", "rtspu", "rsync", "svn", "svn+ssh", "sftp",
"nfs", "git", "git+ssh", "ws", "wss", "itms-services",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UrlError(pub String);
impl std::fmt::Display for UrlError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for UrlError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SplitResult {
pub scheme: String,
pub netloc: String,
pub path: String,
pub query: String,
pub fragment: String,
}
fn is_scheme_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.'
}
fn is_c0_or_space(c: char) -> bool {
(c as u32) <= 0x20
}
fn split_netloc(url: &str, start: usize) -> (&str, &str) {
let mut delim = url.len();
for c in ['/', '?', '#'] {
if let Some(pos) = url[start..].find(c) {
delim = delim.min(start + pos);
}
}
(&url[start..delim], &url[delim..])
}
fn check_bracketed_netloc(netloc: &str) -> Result<(), UrlError> {
let hostname_and_port = match netloc.rfind('@') {
Some(at) => &netloc[at + 1..],
None => netloc,
};
let hostname = match hostname_and_port.find('[') {
Some(ob) => {
if ob != 0 {
return Err(UrlError("Invalid IPv6 URL".into()));
}
let bracketed = &hostname_and_port[ob + 1..];
let (hostname, port) = match bracketed.find(']') {
Some(cb) => (&bracketed[..cb], &bracketed[cb + 1..]),
None => (bracketed, ""),
};
if !port.is_empty() && !port.starts_with(':') {
return Err(UrlError("Invalid IPv6 URL".into()));
}
hostname
}
None => match hostname_and_port.find(':') {
Some(c) => &hostname_and_port[..c],
None => hostname_and_port,
},
};
check_bracketed_host(hostname)
}
fn check_bracketed_host(hostname: &str) -> Result<(), UrlError> {
if let Some(rest) = hostname.strip_prefix('v') {
let valid = match rest.find('.') {
Some(dot) => {
dot > 0
&& rest[..dot].bytes().all(|b| b.is_ascii_hexdigit())
&& rest.len() > dot + 1
}
None => false,
};
if !valid {
return Err(UrlError("IPvFuture address is invalid".into()));
}
Ok(())
} else {
match classify_bracketed_ip(hostname) {
Some(true) => Ok(()), Some(false) => Err(UrlError("An IPv4 address cannot be in brackets".into())),
None => Err(UrlError(format!(
"{hostname:?} does not appear to be an IPv4 or IPv6 address"
))),
}
}
}
fn classify_bracketed_ip(host: &str) -> Option<bool> {
use std::net::{Ipv4Addr, Ipv6Addr};
if let Some((addr, zone)) = host.split_once('%') {
if zone.is_empty() || zone.contains('%') {
return None;
}
return addr.parse::<Ipv6Addr>().ok().map(|_| true);
}
if host.parse::<Ipv6Addr>().is_ok() {
Some(true)
} else if host.parse::<Ipv4Addr>().is_ok() {
Some(false)
} else {
None
}
}
fn checknetloc(netloc: &str) -> Result<(), UrlError> {
if netloc.is_empty() || netloc.is_ascii() {
return Ok(());
}
use unicode_normalization::UnicodeNormalization;
let n: String = netloc.chars().filter(|c| !matches!(c, '@' | ':' | '#' | '?')).collect();
let netloc2: String = n.nfkc().collect();
if n == netloc2 {
return Ok(());
}
if netloc2.contains(['/', '?', '#', '@', ':']) {
return Err(UrlError(format!(
"netloc '{netloc}' contains invalid characters under NFKC normalization"
)));
}
Ok(())
}
pub fn urlsplit(url: &str, scheme: &str, allow_fragments: bool) -> Result<SplitResult, UrlError> {
let mut url = url.trim_start_matches(is_c0_or_space).replace(['\t', '\r', '\n'], "");
let mut scheme = scheme.trim_matches(is_c0_or_space).replace(['\t', '\r', '\n'], "");
let mut netloc = String::new();
let mut query = String::new();
let mut fragment = String::new();
if let Some(i) = url.find(':')
&& i > 0
&& url.as_bytes()[0].is_ascii_alphabetic()
&& url[..i].chars().all(is_scheme_char)
{
scheme = url[..i].to_ascii_lowercase();
url = url[i + 1..].to_string();
}
if url.starts_with("//") {
let (nl, rest) = split_netloc(&url, 2);
netloc = nl.to_string();
let has_open = netloc.contains('[');
let has_close = netloc.contains(']');
if has_open != has_close {
return Err(UrlError("Invalid IPv6 URL".into()));
}
if has_open && has_close {
check_bracketed_netloc(&netloc)?;
}
url = rest.to_string();
}
if allow_fragments && let Some(pos) = url.find('#') {
fragment = url[pos + 1..].to_string();
url.truncate(pos);
}
if let Some(pos) = url.find('?') {
query = url[pos + 1..].to_string();
url.truncate(pos);
}
checknetloc(&netloc)?;
Ok(SplitResult { scheme, netloc, path: url, query, fragment })
}
pub fn urlunsplit(
scheme: &str,
netloc: &str,
path: &str,
query: &str,
fragment: &str,
) -> String {
let mut url = path.to_string();
if !netloc.is_empty() {
if !url.is_empty() && !url.starts_with('/') {
url = format!("/{url}");
}
url = format!("//{netloc}{url}");
} else if url.starts_with("//")
|| (!scheme.is_empty()
&& USES_NETLOC.contains(&scheme)
&& (url.is_empty() || url.starts_with('/')))
{
url = format!("//{url}");
}
if !scheme.is_empty() {
url = format!("{scheme}:{url}");
}
if !query.is_empty() {
url = format!("{url}?{query}");
}
if !fragment.is_empty() {
url = format!("{url}#{fragment}");
}
url
}
fn netloc_userinfo(netloc: &str) -> (Option<&str>, Option<&str>) {
match netloc.rfind('@') {
Some(at) => {
let userinfo = &netloc[..at];
match userinfo.find(':') {
Some(c) => (Some(&userinfo[..c]), Some(&userinfo[c + 1..])),
None => (Some(userinfo), None),
}
}
None => (None, None),
}
}
fn netloc_hostinfo(netloc: &str) -> (&str, Option<&str>) {
let hostinfo = match netloc.rfind('@') {
Some(at) => &netloc[at + 1..],
None => netloc,
};
let (hostname, port) = if let Some(ob) = hostinfo.find('[') {
let bracketed = &hostinfo[ob + 1..];
let (hostname, after) = match bracketed.find(']') {
Some(cb) => (&bracketed[..cb], &bracketed[cb + 1..]),
None => (bracketed, ""),
};
let port = match after.find(':') {
Some(c) => &after[c + 1..],
None => "",
};
(hostname, port)
} else {
match hostinfo.find(':') {
Some(c) => (&hostinfo[..c], &hostinfo[c + 1..]),
None => (hostinfo, ""),
}
};
(hostname, if port.is_empty() { None } else { Some(port) })
}
fn netloc_hostname(netloc: &str) -> Option<String> {
let (host, _) = netloc_hostinfo(netloc);
if host.is_empty() {
return None;
}
Some(match host.find('%') {
Some(p) => format!("{}{}", host[..p].to_lowercase(), &host[p..]),
None => host.to_lowercase(),
})
}
fn netloc_port(netloc: &str) -> Result<Option<u16>, UrlError> {
let (_, port) = netloc_hostinfo(netloc);
let Some(port) = port else { return Ok(None) };
if port.bytes().all(|b| b.is_ascii_digit()) {
match port.parse::<u64>() {
Ok(n) if n <= 65535 => Ok(Some(n as u16)),
_ => Err(UrlError("Port out of range 0-65535".into())),
}
} else {
Err(UrlError(format!(
"Port could not be cast to integer value as '{port}'"
)))
}
}
impl SplitResult {
pub fn geturl(&self) -> String {
urlunsplit(&self.scheme, &self.netloc, &self.path, &self.query, &self.fragment)
}
pub fn username(&self) -> Option<&str> {
netloc_userinfo(&self.netloc).0
}
pub fn password(&self) -> Option<&str> {
netloc_userinfo(&self.netloc).1
}
pub fn hostname(&self) -> Option<String> {
netloc_hostname(&self.netloc)
}
pub fn port(&self) -> Result<Option<u16>, UrlError> {
netloc_port(&self.netloc)
}
}
const USES_PARAMS: &[&str] = &[
"", "ftp", "hdl", "prospero", "http", "imap", "https", "shttp", "rtsp", "rtsps", "rtspu",
"sip", "sips", "mms", "sftp", "tel",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseResult {
pub scheme: String,
pub netloc: String,
pub path: String,
pub params: String,
pub query: String,
pub fragment: String,
}
impl ParseResult {
pub fn geturl(&self) -> String {
urlunparse(
&self.scheme, &self.netloc, &self.path, &self.params, &self.query, &self.fragment,
)
}
pub fn username(&self) -> Option<&str> {
netloc_userinfo(&self.netloc).0
}
pub fn password(&self) -> Option<&str> {
netloc_userinfo(&self.netloc).1
}
pub fn hostname(&self) -> Option<String> {
netloc_hostname(&self.netloc)
}
pub fn port(&self) -> Result<Option<u16>, UrlError> {
netloc_port(&self.netloc)
}
}
fn split_params(url: &str) -> (&str, &str) {
let i = if let Some(slash) = url.rfind('/') {
match url[slash..].find(';') {
Some(rel) => slash + rel,
None => return (url, ""),
}
} else {
match url.find(';') {
Some(pos) => pos,
None => return (url, ""),
}
};
(&url[..i], &url[i + 1..])
}
pub fn urlparse(url: &str, scheme: &str, allow_fragments: bool) -> Result<ParseResult, UrlError> {
let s = urlsplit(url, scheme, allow_fragments)?;
let (path, params) = if USES_PARAMS.contains(&s.scheme.as_str()) && s.path.contains(';') {
let (p, pr) = split_params(&s.path);
(p.to_string(), pr.to_string())
} else {
(s.path, String::new())
};
Ok(ParseResult {
scheme: s.scheme,
netloc: s.netloc,
path,
params,
query: s.query,
fragment: s.fragment,
})
}
pub fn urlunparse(
scheme: &str,
netloc: &str,
path: &str,
params: &str,
query: &str,
fragment: &str,
) -> String {
let path = if params.is_empty() {
path.to_string()
} else {
format!("{path};{params}")
};
urlunsplit(scheme, netloc, &path, query, fragment)
}
const USES_RELATIVE: &[&str] = &[
"", "ftp", "http", "gopher", "nntp", "imap", "wais", "file", "https", "shttp", "mms",
"prospero", "rtsp", "rtsps", "rtspu", "sftp", "svn", "svn+ssh", "ws", "wss",
];
pub fn urljoin(base: &str, url: &str, allow_fragments: bool) -> Result<String, UrlError> {
if base.is_empty() {
return Ok(url.to_string());
}
if url.is_empty() {
return Ok(base.to_string());
}
let b = urlparse(base, "", allow_fragments)?;
let u = urlparse(url, &b.scheme, allow_fragments)?;
let scheme = u.scheme;
let mut netloc = u.netloc;
let mut path = u.path;
let mut params = u.params;
let mut query = u.query;
let fragment = u.fragment;
if scheme != b.scheme || !USES_RELATIVE.contains(&scheme.as_str()) {
return Ok(url.to_string());
}
if USES_NETLOC.contains(&scheme.as_str()) {
if !netloc.is_empty() {
return Ok(urlunparse(&scheme, &netloc, &path, ¶ms, &query, &fragment));
}
netloc = b.netloc.clone();
}
if path.is_empty() && params.is_empty() {
path = b.path.clone();
params = b.params.clone();
if query.is_empty() {
query = b.query.clone();
}
return Ok(urlunparse(&scheme, &netloc, &path, ¶ms, &query, &fragment));
}
let mut base_parts: Vec<&str> = b.path.split('/').collect();
if base_parts.last() != Some(&"") {
base_parts.pop();
}
let segments: Vec<&str> = if path.starts_with('/') {
path.split('/').collect()
} else {
let mut segs = base_parts;
segs.extend(path.split('/'));
if segs.len() > 2 {
let mut rebuilt = Vec::with_capacity(segs.len());
rebuilt.push(segs[0]);
rebuilt.extend(segs[1..segs.len() - 1].iter().copied().filter(|s| !s.is_empty()));
rebuilt.push(segs[segs.len() - 1]);
segs = rebuilt;
}
segs
};
let mut resolved: Vec<&str> = Vec::with_capacity(segments.len());
for &seg in &segments {
match seg {
".." => {
resolved.pop(); }
"." => continue,
_ => resolved.push(seg),
}
}
if matches!(segments.last(), Some(&"." | &"..")) {
resolved.push("");
}
let joined = resolved.join("/");
let resolved_path = if joined.is_empty() { "/".to_string() } else { joined };
Ok(urlunparse(&scheme, &netloc, &resolved_path, ¶ms, &query, &fragment))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DefragResult {
pub url: String,
pub fragment: String,
}
impl DefragResult {
pub fn geturl(&self) -> String {
if self.fragment.is_empty() {
self.url.clone()
} else {
format!("{}#{}", self.url, self.fragment)
}
}
}
pub fn urldefrag(url: &str) -> Result<DefragResult, UrlError> {
if url.contains('#') {
let p = urlparse(url, "", true)?;
let defrag = urlunparse(&p.scheme, &p.netloc, &p.path, &p.params, &p.query, "");
Ok(DefragResult { url: defrag, fragment: p.fragment })
} else {
Ok(DefragResult { url: url.to_string(), fragment: String::new() })
}
}
pub fn parse_qsl(
qs: &str,
keep_blank_values: bool,
strict_parsing: bool,
max_num_fields: Option<usize>,
separator: &str,
) -> Result<Vec<(String, String)>, UrlError> {
if separator.is_empty() {
return Err(UrlError("Separator must be of type string or bytes.".into()));
}
if qs.is_empty() {
return Ok(Vec::new());
}
if let Some(max) = max_num_fields {
let num_fields = 1 + qs.matches(separator).count();
if max < num_fields {
return Err(UrlError("Max number of fields exceeded".into()));
}
}
let mut r = Vec::new();
for name_value in qs.split(separator) {
if name_value.is_empty() && !strict_parsing {
continue;
}
let (name, has_eq, value) = match name_value.find('=') {
Some(i) => (&name_value[..i], true, &name_value[i + 1..]),
None => (name_value, false, ""),
};
if !has_eq && strict_parsing {
return Err(UrlError(format!("bad query field: '{name_value}'")));
}
if !value.is_empty() || keep_blank_values {
r.push((unquote_plus(name), unquote_plus(value)));
}
}
Ok(r)
}
pub fn parse_qs(
qs: &str,
keep_blank_values: bool,
strict_parsing: bool,
max_num_fields: Option<usize>,
separator: &str,
) -> Result<Vec<(String, Vec<String>)>, UrlError> {
let pairs = parse_qsl(qs, keep_blank_values, strict_parsing, max_num_fields, separator)?;
let mut result: Vec<(String, Vec<String>)> = Vec::new();
for (name, value) in pairs {
match result.iter_mut().find(|(k, _)| *k == name) {
Some(entry) => entry.1.push(value),
None => result.push((name, vec![value])),
}
}
Ok(result)
}
pub fn urlencode(query: &[(&str, &str)], safe: &str) -> String {
query
.iter()
.map(|(k, v)| format!("{}={}", quote_plus(k, safe), quote_plus(v, safe)))
.collect::<Vec<_>>()
.join("&")
}
pub fn urlencode_doseq(query: &[(&str, &[&str])], safe: &str) -> String {
let mut parts = Vec::new();
for (k, seq) in query {
let key = quote_plus(k, safe);
for elt in *seq {
parts.push(format!("{key}={}", quote_plus(elt, safe)));
}
}
parts.join("&")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quote_basics() {
assert_eq!(quote("abc def", "/"), "abc%20def");
assert_eq!(quote("a/b/c", "/"), "a/b/c");
assert_eq!(quote("a/b/c", ""), "a%2Fb%2Fc");
assert_eq!(quote("~_.-", "/"), "~_.-");
assert_eq!(quote("/El Niño/", "/"), "/El%20Ni%C3%B1o/");
}
#[test]
fn quote_plus_basics() {
assert_eq!(quote_plus("a b c", ""), "a+b+c");
assert_eq!(quote_plus("a+b", ""), "a%2Bb");
assert_eq!(quote_plus("/El Niño/", ""), "%2FEl+Ni%C3%B1o%2F");
}
#[test]
fn unquote_basics() {
assert_eq!(unquote("abc%20def"), "abc def");
assert_eq!(unquote("no-escapes"), "no-escapes");
assert_eq!(unquote("/El%20Ni%C3%B1o/"), "/El Niño/");
assert_eq!(unquote("%zz%"), "%zz%");
assert_eq!(unquote("100%"), "100%");
}
#[test]
fn unquote_plus_and_bytes() {
assert_eq!(unquote_plus("%7e/abc+def"), "~/abc def");
assert_eq!(unquote_to_bytes("a%26%EF"), b"a&\xef");
}
#[test]
fn quote_roundtrip_bytes() {
assert_eq!(quote_from_bytes(b"abc def\x3f", "/"), "abc%20def%3F");
assert_eq!(quote_from_bytes(&[], "/"), "");
}
fn split(url: &str) -> SplitResult {
urlsplit(url, "", true).unwrap()
}
#[test]
fn urlsplit_basics() {
let r = split("http://docs.python.org:80/3/library/urllib.parse.html?h=1#frag");
assert_eq!(r.scheme, "http");
assert_eq!(r.netloc, "docs.python.org:80");
assert_eq!(r.path, "/3/library/urllib.parse.html");
assert_eq!(r.query, "h=1");
assert_eq!(r.fragment, "frag");
}
#[test]
fn urlsplit_edge_cases() {
assert_eq!(split("//netloc/path").netloc, "netloc");
assert_eq!(split("path/only").scheme, "");
assert_eq!(split("path/only").path, "path/only");
assert_eq!(split("1http://x").scheme, "");
assert_eq!(split("HTTP://x").scheme, "http");
assert!(urlsplit("//[::1/path", "", true).is_err());
}
#[test]
fn urlsplit_bracketed_hosts() {
for url in [
"http://[::1]:8080/p",
"http://[fe80::1%eth0]:443/",
"http://[::ffff:1.2.3.4]/",
"http://[v1.fe80::a+b-c]/",
] {
assert!(urlsplit(url, "", true).is_ok(), "should accept {url:?}");
}
for url in [
"http://[1.2.3.4]/", "http://[notanip]/", "http://[::1]x/", "http://x[::1]/", "http://[v.x]/", "http://[v1]/", "http://[fe80::1%]/", "http://[fe80::1%a%b]/", ] {
assert!(urlsplit(url, "", true).is_err(), "should reject {url:?}");
}
}
#[test]
fn urlsplit_nfkc_netloc() {
for url in ["http://naïve.com/p", "http://ABC/x", "http://üser@höst:80/"] {
assert!(urlsplit(url, "", true).is_ok(), "should accept {url:?}");
}
for url in [
"http://exa℀mple/",
"http://h℅o/",
"http://exa/mple/",
"http://user@ho@st/",
"http://h:p/",
] {
assert!(urlsplit(url, "", true).is_err(), "should reject {url:?}");
}
}
#[test]
fn urlsplit_roundtrip() {
for url in [
"http://a.com/b?c#d",
"https://user:pass@host:8080/p",
"mailto:a@b.com",
"//host/path",
"path",
"?query",
"#frag",
] {
let r = split(url);
assert_eq!(r.geturl(), url, "roundtrip failed for {url:?}");
}
}
#[test]
fn netloc_accessors() {
let r = split("http://user:pw@Example.COM:8080/p");
assert_eq!(r.username(), Some("user"));
assert_eq!(r.password(), Some("pw"));
assert_eq!(r.hostname().as_deref(), Some("example.com"));
assert_eq!(r.port(), Ok(Some(8080)));
let r = split("http://host/p");
assert_eq!(r.username(), None);
assert_eq!(r.password(), None);
assert_eq!(r.port(), Ok(None));
let r = split("http://[FE80::1%tESt]:1234/");
assert_eq!(r.hostname().as_deref(), Some("fe80::1%tESt"));
assert_eq!(r.port(), Ok(Some(1234)));
assert!(split("http://host:notaport/").port().is_err());
assert!(split("http://host:99999/").port().is_err());
}
#[test]
fn urlparse_params() {
let r = urlparse("http://h/a/b;type=d?q=1#f", "", true).unwrap();
assert_eq!(r.path, "/a/b");
assert_eq!(r.params, "type=d");
assert_eq!(r.query, "q=1");
assert_eq!(r.fragment, "f");
let r = urlparse("http://h/a;x/b", "", true).unwrap();
assert_eq!(r.path, "/a;x/b");
assert_eq!(r.params, "");
let r = urlparse("git://h/a;b", "", true).unwrap();
assert_eq!(r.path, "/a;b");
assert_eq!(r.params, "");
}
#[test]
fn urlparse_roundtrip() {
for url in [
"http://h/a/b;type=d?q=1#f",
"https://user:pass@host:8080/p;k=v?x#y",
"mailto:a@b.com",
"scheme:opaque",
"//host/path",
] {
let r = urlparse(url, "", true).unwrap();
assert_eq!(r.geturl(), url, "roundtrip failed for {url:?}");
}
}
fn join(base: &str, rel: &str) -> String {
urljoin(base, rel, true).unwrap()
}
#[test]
fn urljoin_rfc3986() {
let base = "http://a/b/c/d;p?q";
assert_eq!(join(base, "g"), "http://a/b/c/g");
assert_eq!(join(base, "./g"), "http://a/b/c/g");
assert_eq!(join(base, "/g"), "http://a/g");
assert_eq!(join(base, "//g"), "http://g");
assert_eq!(join(base, "?y"), "http://a/b/c/d;p?y");
assert_eq!(join(base, "#s"), "http://a/b/c/d;p?q#s");
assert_eq!(join(base, ".."), "http://a/b/");
assert_eq!(join(base, "../g"), "http://a/b/g");
assert_eq!(join(base, "../../g"), "http://a/g");
assert_eq!(join(base, "http:g"), "http://a/b/c/g");
assert_eq!(join(base, ""), base);
assert_eq!(join("", "g"), "g");
}
#[test]
fn urldefrag_basics() {
let r = urldefrag("http://h/p?q#frag").unwrap();
assert_eq!(r.url, "http://h/p?q");
assert_eq!(r.fragment, "frag");
assert_eq!(r.geturl(), "http://h/p?q#frag");
let r = urldefrag("http://h/p?q").unwrap();
assert_eq!(r.url, "http://h/p?q");
assert_eq!(r.fragment, "");
}
#[test]
fn parse_qsl_basics() {
assert_eq!(
parse_qsl("a=1&b=2&a=3", false, false, None, "&").unwrap(),
vec![
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string()),
("a".to_string(), "3".to_string()),
]
);
assert_eq!(parse_qsl("a=&b=2", false, false, None, "&").unwrap().len(), 1);
assert_eq!(parse_qsl("a=&b=2", true, false, None, "&").unwrap().len(), 2);
assert_eq!(
parse_qsl("k=%41+B", false, false, None, "&").unwrap(),
vec![("k".to_string(), "A B".to_string())]
);
assert!(parse_qsl("a=1&b", false, true, None, "&").is_err());
assert!(parse_qsl("a=1&b=2&c=3", false, false, Some(2), "&").is_err());
}
#[test]
fn parse_qs_groups() {
assert_eq!(
parse_qs("a=1&b=2&a=3", false, false, None, "&").unwrap(),
vec![
("a".to_string(), vec!["1".to_string(), "3".to_string()]),
("b".to_string(), vec!["2".to_string()]),
]
);
}
#[test]
fn urlencode_basics() {
assert_eq!(urlencode(&[("a", "1"), ("b", "2")], ""), "a=1&b=2");
assert_eq!(urlencode(&[("name", "El Niño")], ""), "name=El+Ni%C3%B1o");
assert_eq!(urlencode(&[], ""), "");
assert_eq!(
urlencode_doseq(&[("a", &["1", "2", "3"][..])], ""),
"a=1&a=2&a=3"
);
}
}