Skip to main content

internetarchive_rs/
search.rs

1//! Advanced-search query builder.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use url::Url;
7
8/// Sort direction used by advanced search.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
10pub enum SortDirection {
11    /// Ascending sort.
12    Asc,
13    /// Descending sort.
14    Desc,
15}
16
17impl SortDirection {
18    #[must_use]
19    fn as_str(self) -> &'static str {
20        match self {
21            Self::Asc => "asc",
22            Self::Desc => "desc",
23        }
24    }
25}
26
27/// One sort clause for advanced search.
28#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
29pub struct SearchSort {
30    /// Field name to sort by.
31    pub field: String,
32    /// Sort direction.
33    pub direction: SortDirection,
34}
35
36impl SearchSort {
37    /// Creates a new sort clause.
38    #[must_use]
39    pub fn new(field: impl Into<String>, direction: SortDirection) -> Self {
40        Self {
41            field: field.into(),
42            direction,
43        }
44    }
45
46    #[must_use]
47    pub(crate) fn as_param(&self) -> String {
48        format!("{} {}", self.field, self.direction.as_str())
49    }
50}
51
52/// Query object for `advancedsearch.php`.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct SearchQuery {
55    query: String,
56    fields: Vec<String>,
57    rows: Option<u32>,
58    page: Option<u32>,
59    sorts: Vec<SearchSort>,
60    extra_params: BTreeMap<String, String>,
61}
62
63impl SearchQuery {
64    /// Creates a raw search query string.
65    #[must_use]
66    pub fn new(query: impl Into<String>) -> Self {
67        Self {
68            query: query.into(),
69            fields: Vec::new(),
70            rows: None,
71            page: None,
72            sorts: Vec::new(),
73            extra_params: BTreeMap::new(),
74        }
75    }
76
77    /// Creates an identifier-only query.
78    #[must_use]
79    pub fn identifier(identifier: impl AsRef<str>) -> Self {
80        Self::new(format!("identifier:{}", identifier.as_ref()))
81    }
82
83    /// Starts building a search query.
84    #[must_use]
85    pub fn builder(query: impl Into<String>) -> SearchQueryBuilder {
86        SearchQueryBuilder {
87            inner: Self::new(query),
88        }
89    }
90
91    /// Returns the raw query string.
92    #[must_use]
93    pub fn query(&self) -> &str {
94        &self.query
95    }
96
97    /// Returns the requested fields.
98    #[must_use]
99    pub fn fields(&self) -> &[String] {
100        &self.fields
101    }
102
103    /// Encodes the query onto a search endpoint URL.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if query parameters cannot be appended.
108    pub fn into_url(&self, mut url: Url) -> Result<Url, url::ParseError> {
109        {
110            let mut query_pairs = url.query_pairs_mut();
111            query_pairs
112                .append_pair("q", &self.query)
113                .append_pair("output", "json");
114
115            if !self.fields.is_empty() {
116                for field in &self.fields {
117                    query_pairs.append_pair("fl[]", field);
118                }
119            }
120
121            if let Some(rows) = self.rows {
122                query_pairs.append_pair("rows", &rows.to_string());
123            }
124
125            if let Some(page) = self.page {
126                query_pairs.append_pair("page", &page.to_string());
127            }
128
129            for sort in &self.sorts {
130                query_pairs.append_pair("sort[]", &sort.as_param());
131            }
132
133            for (key, value) in &self.extra_params {
134                query_pairs.append_pair(key, value);
135            }
136        }
137
138        Ok(url)
139    }
140}
141
142/// Builder for [`SearchQuery`].
143#[derive(Clone, Debug, PartialEq, Eq)]
144pub struct SearchQueryBuilder {
145    inner: SearchQuery,
146}
147
148impl SearchQueryBuilder {
149    /// Adds a field to the response.
150    #[must_use]
151    pub fn field(mut self, field: impl Into<String>) -> Self {
152        self.inner.fields.push(field.into());
153        self
154    }
155
156    /// Sets the page size.
157    #[must_use]
158    pub fn rows(mut self, rows: u32) -> Self {
159        self.inner.rows = Some(rows);
160        self
161    }
162
163    /// Sets the page number.
164    #[must_use]
165    pub fn page(mut self, page: u32) -> Self {
166        self.inner.page = Some(page);
167        self
168    }
169
170    /// Adds a sort clause.
171    #[must_use]
172    pub fn sort(mut self, field: impl Into<String>, direction: SortDirection) -> Self {
173        self.inner.sorts.push(SearchSort::new(field, direction));
174        self
175    }
176
177    /// Appends a raw extra query parameter.
178    #[must_use]
179    pub fn extra_param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
180        self.inner.extra_params.insert(key.into(), value.into());
181        self
182    }
183
184    /// Builds the query.
185    #[must_use]
186    pub fn build(self) -> SearchQuery {
187        self.inner
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{SearchQuery, SearchSort, SortDirection};
194    use url::Url;
195
196    #[test]
197    fn search_query_encodes_common_parameters() {
198        let query = SearchQuery::builder("identifier:xfetch")
199            .field("identifier")
200            .field("title")
201            .rows(10)
202            .page(2)
203            .sort("publicdate", SortDirection::Desc)
204            .build();
205
206        let url = query
207            .into_url(Url::parse("https://archive.org/advancedsearch.php").unwrap())
208            .unwrap();
209
210        assert!(url.as_str().contains("q=identifier%3Axfetch"));
211        assert!(url.as_str().contains("fl%5B%5D=identifier"));
212        assert!(url.as_str().contains("rows=10"));
213        assert!(url.as_str().contains("page=2"));
214        assert!(url.as_str().contains("sort%5B%5D=publicdate+desc"));
215        assert!(url.as_str().contains("output=json"));
216    }
217
218    #[test]
219    fn identifier_query_accessors_and_extra_params_work() {
220        let query = SearchQuery::builder(SearchQuery::identifier("demo-item").query())
221            .field("identifier")
222            .extra_param("mediatype", "texts")
223            .sort("title", SortDirection::Asc)
224            .build();
225
226        assert_eq!(query.query(), "identifier:demo-item");
227        assert_eq!(query.fields(), &["identifier".to_owned()]);
228
229        let url = query
230            .into_url(Url::parse("https://archive.org/advancedsearch.php").unwrap())
231            .unwrap();
232        assert!(url.as_str().contains("mediatype=texts"));
233        assert!(url.as_str().contains("sort%5B%5D=title+asc"));
234    }
235
236    #[test]
237    fn search_sort_new_preserves_field_and_direction() {
238        let sort = SearchSort::new("publicdate", SortDirection::Asc);
239        assert_eq!(sort.field, "publicdate");
240        assert_eq!(sort.direction, SortDirection::Asc);
241        assert_eq!(sort.as_param(), "publicdate asc");
242    }
243}