//! URL and query string parsing for pijul HTTP protocol.
use std::collections::HashMap;
/// Decode URL percent-encoded string.
///
/// Handles:
/// - `%XX` hex escapes (e.g., `%20` -> space)
/// - `+` as space (application/x-www-form-urlencoded convention)
///
/// # Pijul Protocol Usage
/// Used to decode channel names and other parameters that may contain
/// special characters in the query string.
fn percent_decode(s: &str) -> String {
let mut result = String::new();
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match c {
'%' => {
let hex: String = chars.by_ref().take(2).collect();
if hex.len() == 2
&& let Ok(byte) = u8::from_str_radix(&hex, 16)
{
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex)
}
}
'+' => result.push(' '),
_ => result.push(c),
}
}
result
}
/// Parse a query string into a multi-value parameter map.
///
/// Handles:
/// - `key=value` pairs separated by `&`
/// - Keys without values (e.g., `?id` -> `{"id": [""]}``)
/// - Multiple values for the same key (e.g., `?path=a&path=b`)
/// - Percent-decoded values
///
/// # Pijul Protocol Usage
/// The pijul protocol sometimes uses repeated parameters (e.g., `path` can
/// appear multiple times for filtering). This parser preserves all values.
fn parse_query(query_str: &str) -> HashMap<String, Vec<String>> {
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for pair in query_str.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
map.entry(k.to_string())
.or_default()
.push(percent_decode(v));
}
map
}
/// Parse a request URL into path and query components.
///
/// # Returns
/// Tuple of (path, Query) where:
/// - `path`: URL path with leading `/` stripped (e.g., `".pijul"`)
/// - `Query`: Parsed query parameters
///
/// # Pijul Protocol Usage
/// The pijul HTTP protocol routes all requests to `/.pijul` with query
/// parameters determining the operation. The path is checked to ensure
/// it starts with `.pijul` before processing.
///
/// # Example
/// ```
/// use pijul_srv_lite::url::parse_url;
/// let (path, query) = parse_url("/.pijul?channel=main&id");
/// assert_eq!(path, ".pijul");
/// assert_eq!(query.get("channel").unwrap().as_slice(), ["main"]);
/// assert_eq!(query.get("id").unwrap().len(), 1);
/// ```
pub fn parse_url(url: &str) -> (String, HashMap<String, Vec<String>>) {
let (path, query_str) = url.split_once('?').unwrap_or((url, ""));
let path = path.trim_start_matches('/');
(path.into(), parse_query(query_str))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_query() {
let result = parse_query("param1=hello¶m2=world");
assert_eq!(result.get("param1"), Some(&vec!["hello".to_string()]));
assert_eq!(result.get("param2"), Some(&vec!["world".to_string()]));
let result = parse_query("param1=hello¶m2=world¶m3");
assert_eq!(result.get("param3"), Some(&vec!["".to_string()]));
let result = parse_query("param1=hello¶m2=world¶m3¶m4=next%20param");
assert_eq!(result.get("param4"), Some(&vec!["next param".to_string()]));
}
#[test]
fn test_parse_query_multi_value() {
let result = parse_query("path=src&path=lib&channel=main");
assert_eq!(
result.get("path"),
Some(&vec!["src".to_string(), "lib".to_string()])
);
assert_eq!(result.get("channel"), Some(&vec!["main".to_string()]));
}
#[test]
fn test_parse_url() {
let (path, query) = parse_url("/.pijul");
assert_eq!(path, ".pijul");
assert!(query.is_empty());
let (path, query) = parse_url("/.pijul?channel=main&id");
assert_eq!(path, ".pijul");
assert_eq!(query.get("channel"), Some(&vec!["main".to_string()]));
assert_eq!(query.get("id"), Some(&vec!["".to_string()]));
}
#[test]
fn test_percent_decode() {
assert_eq!(percent_decode("Hello%20World%21"), "Hello World!");
assert_eq!(
percent_decode("~%20is%20where%20the%20%3C3%20is"),
"~ is where the <3 is"
);
// ascii extended not allowed
assert_ne!(
percent_decode("My%20pijul%20channel%20is%20named%20%22G%C3%B6ta+kanal%22"),
"My pijul channel is named \"Göta kanal\""
);
}
}