1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
//! # A query string builder for percent encoding key-value pairs
//!
//! This is a tiny helper crate for simplifying the construction of URL query strings.
//! The initial `?` question mark is automatically prepended.
//!
//! ## Example
//!
//! ```
//! use query_string_builder::QueryString;
//!
//! let qs = QueryString::new()
//!             .with_value("q", "apple")
//!             .with_value("category", "fruits and vegetables");
//!
//! assert_eq!(
//!     format!("https://example.com/{qs}"),
//!     "https://example.com/?q=apple&category=fruits%20and%20vegetables"
//! );
//! ```

#![deny(unsafe_code)]

use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use std::fmt::{Debug, Display, Formatter};

/// https://url.spec.whatwg.org/#fragment-percent-encode-set
const FRAGMENT: &AsciiSet = &CONTROLS.add(b' ').add(b'"').add(b'<').add(b'>').add(b'`');

/// A query string builder for percent encoding key-value pairs.
///
/// ## Example
///
/// ```
/// use query_string_builder::QueryString;
///
/// let qs = QueryString::new()
///             .with_value("q", "apple")
///             .with_value("category", "fruits and vegetables");
///
/// assert_eq!(
///     format!("https://example.com/{qs}"),
///     "https://example.com/?q=apple&category=fruits%20and%20vegetables"
/// );
/// ```
#[derive(Debug, Default, Clone)]
pub struct QueryString<'a> {
    pairs: Vec<Kvp<'a>>,
}

impl<'a> QueryString<'a> {
    /// Creates a new, empty query string builder.
    pub fn new() -> Self {
        Self {
            pairs: Vec::default(),
        }
    }

    /// Appends a key-value pair to the query string.
    pub fn with_value(mut self, key: &'a str, value: &'a str) -> Self {
        self.pairs.push(Kvp { key, value });
        self
    }

    /// Appends a key-value pair to the query string.
    pub fn push(&mut self, key: &'a str, value: &'a str) -> &Self {
        self.pairs.push(Kvp { key, value });
        self
    }

    /// Determines the number of key-value pairs currently in the builder.
    pub fn len(&self) -> usize {
        self.pairs.len()
    }

    /// Determines if the builder is currently empty.
    pub fn is_empty(&self) -> bool {
        self.pairs.is_empty()
    }
}

impl<'a> Display for QueryString<'a> {
    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
        if self.pairs.is_empty() {
            return Ok(());
        } else {
            write!(f, "?")?;
            for (i, pair) in self.pairs.iter().enumerate() {
                if i > 0 {
                    write!(f, "&")?;
                }
                write!(
                    f,
                    "{key}={value}",
                    key = utf8_percent_encode(pair.key, FRAGMENT),
                    value = utf8_percent_encode(pair.value, FRAGMENT)
                )?;
            }
            Ok(())
        }
    }
}

#[derive(Debug, Clone)]
struct Kvp<'a> {
    key: &'a str,
    value: &'a str,
}

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

    #[test]
    fn test_simple() {
        let qs = QueryString::new()
            .with_value("q", "apple")
            .with_value("category", "fruits and vegetables");
        assert_eq!(
            qs.to_string(),
            "?q=apple&category=fruits%20and%20vegetables"
        );
    }

    #[test]
    fn test_encoding() {
        let qs = QueryString::new()
            .with_value("q", "Grünkohl")
            .with_value("category", "Gemüse");
        assert_eq!(qs.to_string(), "?q=Gr%C3%BCnkohl&category=Gem%C3%BCse");
    }

    #[test]
    fn test_emoji() {
        let qs = QueryString::new()
            .with_value("q", "🥦")
            .with_value("🍽️", "🍔🍕");
        assert_eq!(
            qs.to_string(),
            "?q=%F0%9F%A5%A6&%F0%9F%8D%BD%EF%B8%8F=%F0%9F%8D%94%F0%9F%8D%95"
        );
    }
}