urlable 0.2.0

A comprehensive URL manipulation library for Rust, providing utilities for parsing, encoding, and manipulating URLs with support for query strings, path manipulation, punycode domains and more
Documentation
use crate::encoding::{decode_query_key, decode_query_value, encode_query_key, encode_query_value};
use indexmap::IndexMap;
use serde_json::Value;

pub type QueryValue = Value;
pub type QueryObject = IndexMap<String, QueryValue>;

/// Parses and decodes a query string into an object.
/// The input can be a query string with or without the leading `?`.
///
/// This function handles:
/// - Query strings with/without leading ?
/// - Multiple values for same key (joined with commas)
/// - URL encoded keys and values
/// - Empty values
/// - Malformed query strings
///
/// # Arguments
/// * `parameters_string` - The query string to parse (e.g. "foo=bar&baz=qux" or "?foo=bar")
///
/// # Returns
/// * `T` - A IndexMap containing the parsed query parameters where T implements From<IndexMap>
///
pub fn parse_query(raw_query: &str) -> QueryObject {
    let mut object = QueryObject::new();
    // Strip leading ? if present
    let params_pair = raw_query.strip_prefix('?').unwrap_or(raw_query);

    // Split on & to get key=value pairs
    for parameter in params_pair.split('&') {
        if let Some((key, value)) = parameter.split_once('=') {
            let key = decode_query_key(key);
            let value = decode_query_value(value);

            // Handle multiple values for same key by joining with commas
            match object.get_mut(&key) {
                None => {
                    object.insert(key, Value::String(value));
                }
                Some(existing) => {
                    let existing_str = existing.as_str().unwrap_or("");
                    *existing = Value::String(format!("{},{}", existing_str, value));
                }
            }
        }
    }
    object
}

/// Encodes a key-value pair into a URL query string parameter.
///
/// This function handles:
/// - Null/boolean/number values (key only)
/// - Array values (multiple key=value pairs)
/// - String values (key=value)
/// - URL encoding of keys and values
///
/// # Arguments
/// * `key` - The parameter key to encode
/// * `value` - The parameter value to encode (as JSON Value)
///
/// # Returns
/// * `String` - The encoded query string parameter
pub fn encode_query_item(key: &str, value: &QueryValue) -> String {
    // Handle null/boolean/number values - output key only
    if value.is_null() || value.is_boolean() || value.is_number() {
        return encode_query_key(key);
    }

    // Handle array values - output multiple key=value pairs
    if let Some(arr) = value.as_array() {
        return arr
            .iter()
            .map(|v| {
                format!(
                    "{}={}",
                    encode_query_key(key),
                    encode_query_value(v.as_str().unwrap_or(""))
                )
            })
            .collect::<Vec<String>>()
            .join("&");
    }

    // Handle string values - output key=value
    format!(
        "{}={}",
        encode_query_key(key),
        encode_query_value(value.as_str().unwrap_or(""))
    )
}

/// Converts a query object into an encoded query string.
///
/// This function:
/// - Filters out null values
/// - Encodes each key-value pair
/// - Joins pairs with &
/// - Handles arrays and complex values
///
/// # Arguments
/// * `query` - The query object to stringify (IndexMap of key-value pairs)
///
/// # Returns
/// * `String` - The stringified and encoded query string
pub fn stringify_query(query: &QueryObject) -> String {
    query
        .iter()
        .filter(|(_, v)| !v.is_null())
        .map(|(k, v)| encode_query_item(k, v))
        .filter(|s| !s.is_empty())
        .collect::<Vec<String>>()
        .join("&")
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_parse_query_basic() {
        let query = parse_query("foo=bar&baz=qux");
        assert_eq!(query.get("foo").unwrap(), "bar");
        assert_eq!(query.get("baz").unwrap(), "qux");
    }

    #[test]
    fn test_parse_query_with_question_mark() {
        let query = parse_query("?foo=bar&baz=qux");
        assert_eq!(query.get("foo").unwrap(), "bar");
        assert_eq!(query.get("baz").unwrap(), "qux");
    }

    #[test]
    fn test_parse_query_empty() {
        let query = parse_query("");
        assert!(query.is_empty());
    }

    #[test]
    fn test_parse_query_encoded() {
        let query = parse_query("user=john%20doe&email=test%40example.com");
        assert_eq!(query.get("user").unwrap(), "john doe");
        assert_eq!(query.get("email").unwrap(), "test@example.com");
    }

    #[test]
    fn test_parse_query_multiple_values() {
        let query = parse_query("color=red&color=blue&color=green");
        assert_eq!(query.get("color").unwrap(), "red,blue,green");
    }

    #[test]
    fn test_encode_query_item_basic() {
        let key = "test";
        let value = json!("hello world");
        assert_eq!(encode_query_item(key, &value), "test=hello+world");
    }

    #[test]
    fn test_encode_query_item_array() {
        let key = "test";
        let array_value = json!(["a", "b", "c"]);
        assert_eq!(encode_query_item(key, &array_value), "test=a&test=b&test=c");
    }

    #[test]
    fn test_encode_query_item_special_chars() {
        let key = "email";
        let value = json!("test@example.com");
        assert_eq!(encode_query_item(key, &value), "email=test%40example.com");
    }

    #[test]
    fn test_encode_query_item_null() {
        let key = "empty";
        let value = json!(null);
        assert_eq!(encode_query_item(key, &value), "empty");
    }

    #[test]
    fn test_stringify_query_basic() {
        let mut query = QueryObject::new();
        query.insert("foo".to_string(), json!("bar"));
        query.insert("baz".to_string(), json!("qux"));

        let result = stringify_query(&query);
        assert!(result.contains("foo=bar"));
        assert!(result.contains("baz=qux"));
    }

    #[test]
    fn test_stringify_query_complex() {
        let mut query = QueryObject::new();
        query.insert("user".to_string(), json!("john doe"));
        query.insert("tags".to_string(), json!(["rust", "coding"]));
        query.insert("active".to_string(), json!(true));
        query.insert("empty".to_string(), json!(null));

        let result = stringify_query(&query);
        assert!(result.contains("user=john+doe"));
        assert!(result.contains("tags=rust&tags=coding"));
        assert!(result.contains("active"));
        assert!(!result.contains("empty"));
    }

    #[test]
    fn test_encode_query_item_emoji() {
        let key = "message";
        let value = json!("Hello 👋 World 🌍");
        assert_eq!(
            encode_query_item(key, &value),
            "message=Hello+%F0%9F%91%8B+World+%F0%9F%8C%8D"
        );

        let key = "reaction";
        let value = json!("❤️");
        assert_eq!(
            encode_query_item(key, &value),
            "reaction=%E2%9D%A4%EF%B8%8F"
        );

        let key = "faces";
        let value = json!(["😀", "😎", "🤔"]);
        assert_eq!(
            encode_query_item(key, &value),
            "faces=%F0%9F%98%80&faces=%F0%9F%98%8E&faces=%F0%9F%A4%94"
        );
    }

    #[test]
    fn test_decode_query_item_emoji() {
        let encoded = "http://example.com?message=Hello+%F0%9F%91%8B+World+%F0%9F%8C%8D";
        let url = url::Url::parse(encoded).unwrap();
        let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
        assert_eq!(
            pairs[0],
            ("message".to_string(), "Hello 👋 World 🌍".to_string())
        );

        let encoded = "http://example.com?reaction=%E2%9D%A4%EF%B8%8F";
        let url = url::Url::parse(encoded).unwrap();
        let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
        assert_eq!(pairs[0], ("reaction".to_string(), "❤️".to_string()));

        let encoded = "http://example.com?faces=%F0%9F%98%80";
        let url = url::Url::parse(encoded).unwrap();
        let pairs: Vec<(String, String)> = url.query_pairs().into_owned().collect();
        assert_eq!(pairs[0], ("faces".to_string(), "😀".to_string()));
    }
}