Skip to main content

api_bones/
url.rs

1//! Fluent URL and query-string builders.
2//!
3//! # [`UrlBuilder`]
4//!
5//! A fluent builder for constructing URLs from scheme, host, path segments,
6//! query parameters, and fragment. Path segments are percent-encoded; query
7//! values are form-encoded.
8//!
9//! ```rust
10//! use api_bones::url::UrlBuilder;
11//!
12//! let url = UrlBuilder::new()
13//!     .scheme("https")
14//!     .host("api.example.com")
15//!     .path("v1")
16//!     .path("users")
17//!     .path("42")
18//!     .query("active", "true")
19//!     .build();
20//!
21//! assert_eq!(url, "https://api.example.com/v1/users/42?active=true");
22//! ```
23//!
24//! # [`QueryBuilder`]
25//!
26//! A standalone query-string builder with typed `Display` values and optional
27//! merge into an existing URL.
28//!
29//! ```rust
30//! use api_bones::url::QueryBuilder;
31//!
32//! let qs = QueryBuilder::new()
33//!     .param("limit", 20u32)
34//!     .param("sort", "desc")
35//!     .build();
36//! assert_eq!(qs, "limit=20&sort=desc");
37//! ```
38
39#[cfg(all(not(feature = "std"), feature = "alloc"))]
40use alloc::{
41    string::{String, ToString},
42    vec::Vec,
43};
44use core::fmt;
45#[cfg(feature = "serde")]
46use serde::{Deserialize, Serialize};
47
48// ---------------------------------------------------------------------------
49// Percent-encoding helpers
50// ---------------------------------------------------------------------------
51
52/// Percent-encode a string using the path-segment allowed set (RFC 3986 §3.3).
53///
54/// Unreserved characters (`A-Z a-z 0-9 - . _ ~`) and sub-delimiters
55/// (`: @ ! $ & ' ( ) * + , ; =`) are left as-is. Everything else is encoded.
56#[must_use]
57fn percent_encode_path(s: &str) -> String {
58    let mut out = String::with_capacity(s.len());
59    for byte in s.bytes() {
60        if byte.is_ascii_alphanumeric()
61            || matches!(
62                byte,
63                b'-' | b'.'
64                    | b'_'
65                    | b'~'
66                    | b':'
67                    | b'@'
68                    | b'!'
69                    | b'$'
70                    | b'&'
71                    | b'\''
72                    | b'('
73                    | b')'
74                    | b'*'
75                    | b'+'
76                    | b','
77                    | b';'
78                    | b'='
79            )
80        {
81            out.push(byte as char);
82        } else {
83            let _ = core::fmt::write(&mut out, format_args!("%{byte:02X}"));
84        }
85    }
86    out
87}
88
89/// Percent-encode a query key or value (application/x-www-form-urlencoded style).
90///
91/// Space is encoded as `+`; everything else outside the unreserved set is `%XX`.
92#[must_use]
93fn percent_encode_query(s: &str) -> String {
94    let mut out = String::with_capacity(s.len());
95    for byte in s.bytes() {
96        match byte {
97            b' ' => out.push('+'),
98            b if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~') => {
99                out.push(byte as char);
100            }
101            _ => {
102                let _ = core::fmt::write(&mut out, format_args!("%{byte:02X}"));
103            }
104        }
105    }
106    out
107}
108
109// ---------------------------------------------------------------------------
110// UrlBuilder
111// ---------------------------------------------------------------------------
112
113/// Fluent URL builder.
114///
115/// Build a URL incrementally by chaining setter methods, then call [`build`](Self::build)
116/// to produce the final `String`.
117///
118/// Path segments are automatically percent-encoded. Query parameters are
119/// form-encoded. No validation of scheme or host is performed — this is a
120/// string-composition helper, not a full URL parser.
121#[derive(Debug, Clone, Default)]
122#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
123pub struct UrlBuilder {
124    scheme: Option<String>,
125    host: Option<String>,
126    port: Option<u16>,
127    segments: Vec<String>,
128    query: Vec<(String, String)>,
129    fragment: Option<String>,
130}
131
132impl UrlBuilder {
133    /// Create an empty `UrlBuilder`.
134    #[must_use]
135    pub fn new() -> Self {
136        Self::default()
137    }
138
139    /// Set the URL scheme (e.g. `"https"`).
140    ///
141    /// # Examples
142    ///
143    /// ```
144    /// use api_bones::url::UrlBuilder;
145    ///
146    /// let url = UrlBuilder::new().scheme("https").host("example.com").build();
147    /// assert_eq!(url, "https://example.com");
148    /// ```
149    #[must_use]
150    pub fn scheme(mut self, scheme: impl Into<String>) -> Self {
151        self.scheme = Some(scheme.into());
152        self
153    }
154
155    /// Set the host (e.g. `"api.example.com"`).
156    #[must_use]
157    pub fn host(mut self, host: impl Into<String>) -> Self {
158        self.host = Some(host.into());
159        self
160    }
161
162    /// Set an optional port number.
163    ///
164    /// # Examples
165    ///
166    /// ```
167    /// use api_bones::url::UrlBuilder;
168    ///
169    /// let url = UrlBuilder::new()
170    ///     .scheme("http")
171    ///     .host("localhost")
172    ///     .port(8080)
173    ///     .build();
174    /// assert_eq!(url, "http://localhost:8080");
175    /// ```
176    #[must_use]
177    pub fn port(mut self, port: u16) -> Self {
178        self.port = Some(port);
179        self
180    }
181
182    /// Append a path segment (will be percent-encoded).
183    ///
184    /// Call multiple times to build up `/a/b/c` style paths.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use api_bones::url::UrlBuilder;
190    ///
191    /// let url = UrlBuilder::new()
192    ///     .scheme("https")
193    ///     .host("example.com")
194    ///     .path("v1")
195    ///     .path("users")
196    ///     .path("hello world")
197    ///     .build();
198    /// assert_eq!(url, "https://example.com/v1/users/hello%20world");
199    /// ```
200    #[must_use]
201    pub fn path(mut self, segment: impl Into<String>) -> Self {
202        self.segments.push(segment.into());
203        self
204    }
205
206    /// Append a query parameter (key and value are form-encoded).
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use api_bones::url::UrlBuilder;
212    ///
213    /// let url = UrlBuilder::new()
214    ///     .scheme("https")
215    ///     .host("example.com")
216    ///     .query("q", "hello world")
217    ///     .build();
218    /// assert_eq!(url, "https://example.com?q=hello+world");
219    /// ```
220    #[must_use]
221    #[allow(clippy::needless_pass_by_value)]
222    pub fn query(mut self, key: impl Into<String>, value: impl ToString) -> Self {
223        self.query.push((key.into(), value.to_string()));
224        self
225    }
226
227    /// Set the URL fragment (the part after `#`).
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use api_bones::url::UrlBuilder;
233    ///
234    /// let url = UrlBuilder::new()
235    ///     .scheme("https")
236    ///     .host("example.com")
237    ///     .fragment("section-1")
238    ///     .build();
239    /// assert_eq!(url, "https://example.com#section-1");
240    /// ```
241    #[must_use]
242    pub fn fragment(mut self, fragment: impl Into<String>) -> Self {
243        self.fragment = Some(fragment.into());
244        self
245    }
246
247    /// Produce the final URL string.
248    ///
249    /// # Examples
250    ///
251    /// ```
252    /// use api_bones::url::UrlBuilder;
253    ///
254    /// let url = UrlBuilder::new()
255    ///     .scheme("https")
256    ///     .host("api.example.com")
257    ///     .path("v1")
258    ///     .path("items")
259    ///     .query("page", 2u32)
260    ///     .fragment("top")
261    ///     .build();
262    ///
263    /// assert_eq!(url, "https://api.example.com/v1/items?page=2#top");
264    /// ```
265    #[must_use]
266    pub fn build(&self) -> String {
267        let mut out = String::new();
268
269        // scheme://host[:port]
270        if let Some(scheme) = &self.scheme {
271            out.push_str(scheme);
272            out.push_str("://");
273        }
274        if let Some(host) = &self.host {
275            out.push_str(host);
276        }
277        if let Some(port) = self.port {
278            let _ = core::fmt::write(&mut out, format_args!(":{port}"));
279        }
280
281        // /path/segments
282        for seg in &self.segments {
283            out.push('/');
284            out.push_str(&percent_encode_path(seg));
285        }
286
287        // ?key=value&…
288        for (i, (k, v)) in self.query.iter().enumerate() {
289            out.push(if i == 0 { '?' } else { '&' });
290            out.push_str(&percent_encode_query(k));
291            out.push('=');
292            out.push_str(&percent_encode_query(v));
293        }
294
295        // #fragment
296        if let Some(frag) = &self.fragment {
297            out.push('#');
298            out.push_str(frag);
299        }
300
301        out
302    }
303}
304
305impl fmt::Display for UrlBuilder {
306    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
307        f.write_str(&self.build())
308    }
309}
310
311// ---------------------------------------------------------------------------
312// QueryBuilder
313// ---------------------------------------------------------------------------
314
315/// Standalone query-string builder with typed `Display` values.
316///
317/// Produces `key=value` pairs separated by `&`, with form-encoding applied to
318/// both key and value. Use [`merge_into`](Self::merge_into) to append the
319/// query string to an existing URL.
320///
321/// # Examples
322///
323/// ```
324/// use api_bones::url::QueryBuilder;
325///
326/// let qs = QueryBuilder::new()
327///     .param("limit", 20u32)
328///     .param("sort", "desc")
329///     .build();
330/// assert_eq!(qs, "limit=20&sort=desc");
331/// ```
332#[derive(Debug, Clone, Default)]
333#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
334pub struct QueryBuilder {
335    params: Vec<(String, String)>,
336}
337
338impl QueryBuilder {
339    /// Create an empty `QueryBuilder`.
340    #[must_use]
341    pub fn new() -> Self {
342        Self::default()
343    }
344
345    /// Append a typed query parameter.
346    ///
347    /// The value is converted to a string via [`Display`](core::fmt::Display).
348    ///
349    /// # Examples
350    ///
351    /// ```
352    /// use api_bones::url::QueryBuilder;
353    ///
354    /// let qs = QueryBuilder::new().param("active", true).build();
355    /// assert_eq!(qs, "active=true");
356    /// ```
357    #[must_use]
358    #[allow(clippy::needless_pass_by_value)]
359    pub fn param(mut self, key: impl Into<String>, value: impl ToString) -> Self {
360        self.params.push((key.into(), value.to_string()));
361        self
362    }
363
364    /// Append an optional parameter — skipped if `value` is `None`.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use api_bones::url::QueryBuilder;
370    ///
371    /// let qs = QueryBuilder::new()
372    ///     .param("a", 1u32)
373    ///     .maybe_param("b", None::<&str>)
374    ///     .build();
375    /// assert_eq!(qs, "a=1");
376    /// ```
377    #[must_use]
378    pub fn maybe_param(self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
379        match value {
380            Some(v) => self.param(key, v),
381            None => self,
382        }
383    }
384
385    /// Build the query string (without leading `?`).
386    ///
387    /// Returns an empty string when no parameters have been added.
388    #[must_use]
389    pub fn build(&self) -> String {
390        let mut out = String::new();
391        for (i, (k, v)) in self.params.iter().enumerate() {
392            if i > 0 {
393                out.push('&');
394            }
395            out.push_str(&percent_encode_query(k));
396            out.push('=');
397            out.push_str(&percent_encode_query(v));
398        }
399        out
400    }
401
402    /// Append the query string to `url`, using `?` if there is no existing
403    /// query, or `&` if one already exists.
404    ///
405    /// Returns `url` unchanged when there are no params.
406    ///
407    /// # Examples
408    ///
409    /// ```
410    /// use api_bones::url::QueryBuilder;
411    ///
412    /// let qs = QueryBuilder::new().param("page", 2u32);
413    /// assert_eq!(qs.merge_into("https://example.com"), "https://example.com?page=2");
414    /// assert_eq!(qs.merge_into("https://example.com?limit=20"), "https://example.com?limit=20&page=2");
415    /// ```
416    #[must_use]
417    pub fn merge_into(&self, url: &str) -> String {
418        let qs = self.build();
419        if qs.is_empty() {
420            return url.to_string();
421        }
422        let sep = if url.contains('?') { '&' } else { '?' };
423        let mut out = String::with_capacity(url.len() + 1 + qs.len());
424        out.push_str(url);
425        out.push(sep);
426        out.push_str(&qs);
427        out
428    }
429
430    /// Append a key=value pair — alias for [`param`](Self::param).
431    ///
432    /// # Examples
433    ///
434    /// ```
435    /// use api_bones::url::QueryBuilder;
436    ///
437    /// let qs = QueryBuilder::new().set("limit", 10u32).set("sort", "asc").build();
438    /// assert_eq!(qs, "limit=10&sort=asc");
439    /// ```
440    #[must_use]
441    #[allow(clippy::needless_pass_by_value)]
442    pub fn set(self, key: impl Into<String>, value: impl ToString) -> Self {
443        self.param(key, value)
444    }
445
446    /// Append an optional key=value pair — skipped when `value` is `None`.
447    ///
448    /// Alias for [`maybe_param`](Self::maybe_param).
449    ///
450    /// # Examples
451    ///
452    /// ```
453    /// use api_bones::url::QueryBuilder;
454    ///
455    /// let qs = QueryBuilder::new()
456    ///     .set("a", 1u32)
457    ///     .set_opt("b", None::<&str>)
458    ///     .set_opt("c", Some("yes"))
459    ///     .build();
460    /// assert_eq!(qs, "a=1&c=yes");
461    /// ```
462    #[must_use]
463    pub fn set_opt(self, key: impl Into<String>, value: Option<impl ToString>) -> Self {
464        self.maybe_param(key, value)
465    }
466
467    /// Flatten a serializable struct's top-level fields as query parameters.
468    ///
469    /// The struct is serialized to a JSON object; each field whose value is not
470    /// `null` is appended as a `key=value` pair. Nested objects and arrays are
471    /// serialized as their JSON representation.
472    ///
473    /// Returns an error when `value` cannot be serialized or is not a JSON object.
474    ///
475    /// # Examples
476    ///
477    /// ```
478    /// use api_bones::url::QueryBuilder;
479    /// use serde::Serialize;
480    ///
481    /// #[derive(Serialize)]
482    /// struct Params {
483    ///     page: u32,
484    ///     sort: &'static str,
485    ///     filter: Option<&'static str>,
486    /// }
487    ///
488    /// let params = Params { page: 2, sort: "desc", filter: None };
489    /// let qs = QueryBuilder::new()
490    ///     .extend_from_struct(&params)
491    ///     .unwrap()
492    ///     .build();
493    /// assert_eq!(qs, "page=2&sort=desc");
494    /// ```
495    #[cfg(feature = "serde")]
496    pub fn extend_from_struct<T: serde::Serialize>(
497        mut self,
498        value: &T,
499    ) -> Result<Self, serde_json::Error> {
500        let json = serde_json::to_value(value)?;
501        if let serde_json::Value::Object(map) = json {
502            for (k, v) in map {
503                match v {
504                    serde_json::Value::Null => {}
505                    serde_json::Value::String(s) => {
506                        self.params.push((k, s));
507                    }
508                    other => {
509                        self.params.push((k, other.to_string()));
510                    }
511                }
512            }
513        }
514        Ok(self)
515    }
516
517    /// Append the query string to `url` — alias for [`merge_into`](Self::merge_into).
518    ///
519    /// Uses `?` if the URL has no existing query string, or `&` otherwise.
520    /// Returns `url` unchanged when there are no params.
521    ///
522    /// # Examples
523    ///
524    /// ```
525    /// use api_bones::url::QueryBuilder;
526    ///
527    /// let qs = QueryBuilder::new().set("page", 3u32);
528    /// assert_eq!(qs.merge_into_url("https://api.example.com/items"), "https://api.example.com/items?page=3");
529    /// assert_eq!(qs.merge_into_url("https://api.example.com/items?limit=10"), "https://api.example.com/items?limit=10&page=3");
530    /// ```
531    #[must_use]
532    pub fn merge_into_url(&self, url: &str) -> String {
533        self.merge_into(url)
534    }
535
536    /// Return `true` when no parameters have been added.
537    #[must_use]
538    pub fn is_empty(&self) -> bool {
539        self.params.is_empty()
540    }
541}
542
543impl fmt::Display for QueryBuilder {
544    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
545        f.write_str(&self.build())
546    }
547}
548
549// ---------------------------------------------------------------------------
550// Tests
551// ---------------------------------------------------------------------------
552
553#[cfg(test)]
554mod tests {
555    use super::*;
556
557    // --- percent_encode_path ---
558
559    #[test]
560    fn encode_path_alphanumeric_unchanged() {
561        assert_eq!(percent_encode_path("hello123"), "hello123");
562    }
563
564    #[test]
565    fn encode_path_space_encoded() {
566        assert_eq!(percent_encode_path("hello world"), "hello%20world");
567    }
568
569    #[test]
570    fn encode_path_slash_encoded() {
571        assert_eq!(percent_encode_path("a/b"), "a%2Fb");
572    }
573
574    // --- percent_encode_query ---
575
576    #[test]
577    fn encode_query_space_as_plus() {
578        assert_eq!(percent_encode_query("hello world"), "hello+world");
579    }
580
581    #[test]
582    fn encode_query_ampersand() {
583        assert_eq!(percent_encode_query("a&b"), "a%26b");
584    }
585
586    // --- UrlBuilder ---
587
588    #[test]
589    fn full_url() {
590        let url = UrlBuilder::new()
591            .scheme("https")
592            .host("api.example.com")
593            .path("v1")
594            .path("users")
595            .path("42")
596            .query("active", "true")
597            .fragment("top")
598            .build();
599        assert_eq!(url, "https://api.example.com/v1/users/42?active=true#top");
600    }
601
602    #[test]
603    fn url_with_port() {
604        let url = UrlBuilder::new()
605            .scheme("http")
606            .host("localhost")
607            .port(8080)
608            .path("health")
609            .build();
610        assert_eq!(url, "http://localhost:8080/health");
611    }
612
613    #[test]
614    fn url_path_encoding() {
615        let url = UrlBuilder::new()
616            .scheme("https")
617            .host("example.com")
618            .path("hello world")
619            .build();
620        assert_eq!(url, "https://example.com/hello%20world");
621    }
622
623    #[test]
624    fn url_multiple_query_params() {
625        let url = UrlBuilder::new()
626            .scheme("https")
627            .host("example.com")
628            .query("a", 1u32)
629            .query("b", 2u32)
630            .build();
631        assert_eq!(url, "https://example.com?a=1&b=2");
632    }
633
634    #[test]
635    fn url_no_scheme_no_host() {
636        let url = UrlBuilder::new().path("v1").path("items").build();
637        assert_eq!(url, "/v1/items");
638    }
639
640    #[test]
641    fn display_matches_build() {
642        let b = UrlBuilder::new().scheme("https").host("example.com");
643        assert_eq!(b.to_string(), b.build());
644    }
645
646    // --- QueryBuilder ---
647
648    #[test]
649    fn query_builder_basic() {
650        let qs = QueryBuilder::new()
651            .param("limit", 20u32)
652            .param("sort", "desc")
653            .build();
654        assert_eq!(qs, "limit=20&sort=desc");
655    }
656
657    #[test]
658    fn query_builder_empty() {
659        let qs = QueryBuilder::new().build();
660        assert!(qs.is_empty());
661    }
662
663    #[test]
664    fn query_builder_maybe_param_some() {
665        let qs = QueryBuilder::new()
666            .maybe_param("after", Some("cursor123"))
667            .build();
668        assert_eq!(qs, "after=cursor123");
669    }
670
671    #[test]
672    fn query_builder_maybe_param_none() {
673        let qs = QueryBuilder::new()
674            .param("a", 1u32)
675            .maybe_param("b", None::<&str>)
676            .build();
677        assert_eq!(qs, "a=1");
678    }
679
680    #[test]
681    fn merge_into_no_existing_query() {
682        let qs = QueryBuilder::new().param("page", 2u32);
683        assert_eq!(
684            qs.merge_into("https://example.com"),
685            "https://example.com?page=2"
686        );
687    }
688
689    #[test]
690    fn merge_into_existing_query() {
691        let qs = QueryBuilder::new().param("page", 2u32);
692        assert_eq!(
693            qs.merge_into("https://example.com?limit=20"),
694            "https://example.com?limit=20&page=2"
695        );
696    }
697
698    #[test]
699    fn merge_into_empty_returns_url_unchanged() {
700        let qs = QueryBuilder::new();
701        assert_eq!(qs.merge_into("https://example.com"), "https://example.com");
702    }
703
704    #[test]
705    fn query_builder_url_encodes_special_chars() {
706        let qs = QueryBuilder::new().param("q", "hello world&more").build();
707        assert_eq!(qs, "q=hello+world%26more");
708    }
709
710    // --- UrlBuilder Default ---
711
712    #[test]
713    fn url_builder_default_produces_empty_string() {
714        let b = UrlBuilder::default();
715        assert_eq!(b.build(), "");
716    }
717
718    // --- QueryBuilder Display ---
719
720    #[test]
721    fn query_builder_display_matches_build() {
722        let qb = QueryBuilder::new()
723            .param("limit", 10u32)
724            .param("sort", "asc");
725        assert_eq!(qb.to_string(), qb.build());
726    }
727
728    // --- QueryBuilder is_empty ---
729
730    #[test]
731    fn query_builder_is_empty_true_when_no_params() {
732        assert!(QueryBuilder::new().is_empty());
733    }
734
735    #[test]
736    fn query_builder_is_empty_false_after_param() {
737        assert!(!QueryBuilder::new().param("k", "v").is_empty());
738    }
739
740    // --- merge_into with empty QueryBuilder ---
741
742    #[test]
743    fn merge_into_empty_no_change() {
744        let qb = QueryBuilder::default();
745        assert_eq!(
746            qb.merge_into("https://example.com/path"),
747            "https://example.com/path"
748        );
749    }
750
751    // --- set / set_opt / merge_into_url ---
752
753    #[test]
754    fn set_appends_param() {
755        let qs = QueryBuilder::new()
756            .set("limit", 5u32)
757            .set("sort", "asc")
758            .build();
759        assert_eq!(qs, "limit=5&sort=asc");
760    }
761
762    #[test]
763    fn set_opt_skips_none() {
764        let qs = QueryBuilder::new()
765            .set("a", 1u32)
766            .set_opt("b", None::<&str>)
767            .set_opt("c", Some("yes"))
768            .build();
769        assert_eq!(qs, "a=1&c=yes");
770    }
771
772    #[test]
773    fn merge_into_url_no_existing_query() {
774        let qs = QueryBuilder::new().set("page", 3u32);
775        assert_eq!(
776            qs.merge_into_url("https://example.com"),
777            "https://example.com?page=3"
778        );
779    }
780
781    #[test]
782    fn merge_into_url_with_existing_query() {
783        let qs = QueryBuilder::new().set("page", 3u32);
784        assert_eq!(
785            qs.merge_into_url("https://example.com?limit=10"),
786            "https://example.com?limit=10&page=3"
787        );
788    }
789
790    #[test]
791    fn merge_into_url_empty_unchanged() {
792        let qs = QueryBuilder::new();
793        assert_eq!(
794            qs.merge_into_url("https://example.com"),
795            "https://example.com"
796        );
797    }
798
799    // --- extend_from_struct ---
800
801    #[cfg(feature = "serde")]
802    #[test]
803    fn extend_from_struct_basic() {
804        use serde::Serialize;
805
806        #[derive(Serialize)]
807        struct Params {
808            page: u32,
809            sort: &'static str,
810            filter: Option<&'static str>,
811        }
812
813        let params = Params {
814            page: 2,
815            sort: "desc",
816            filter: None,
817        };
818        let qs = QueryBuilder::new()
819            .extend_from_struct(&params)
820            .unwrap()
821            .build();
822        // page and sort should appear; filter (None) should be omitted
823        assert!(qs.contains("page=2"), "expected page=2 in {qs}");
824        assert!(qs.contains("sort=desc"), "expected sort=desc in {qs}");
825        assert!(!qs.contains("filter"), "filter should be omitted from {qs}");
826    }
827
828    #[cfg(feature = "serde")]
829    #[test]
830    fn extend_from_struct_preserves_existing_params() {
831        use serde::Serialize;
832
833        #[derive(Serialize)]
834        struct Extra {
835            q: &'static str,
836        }
837
838        let qs = QueryBuilder::new()
839            .set("limit", 10u32)
840            .extend_from_struct(&Extra { q: "rust" })
841            .unwrap()
842            .build();
843        assert!(qs.starts_with("limit=10"), "existing param first: {qs}");
844        assert!(qs.contains("q=rust"), "struct field present: {qs}");
845    }
846}