pubmed_client/pubmed/query/
builder.rs

1//! Core SearchQuery builder with basic functionality
2
3use crate::error::Result;
4use crate::pubmed::{PubMedArticle, PubMedClient, SearchResult};
5
6use super::filters::SortOrder;
7
8/// Builder for constructing PubMed search queries
9#[derive(Debug, Clone)]
10pub struct SearchQuery {
11    pub(crate) terms: Vec<String>,
12    pub(crate) filters: Vec<String>,
13    pub(crate) limit: Option<usize>,
14    pub(crate) sort: Option<SortOrder>,
15}
16
17impl SearchQuery {
18    /// Create a new search query builder
19    ///
20    /// # Example
21    ///
22    /// ```
23    /// use pubmed_client::pubmed::SearchQuery;
24    ///
25    /// let query = SearchQuery::new();
26    /// ```
27    pub fn new() -> Self {
28        Self {
29            terms: Vec::new(),
30            filters: Vec::new(),
31            limit: None,
32            sort: None,
33        }
34    }
35
36    /// Add search terms
37    ///
38    /// # Arguments
39    ///
40    /// * `terms` - Search terms to add
41    ///
42    /// # Example
43    ///
44    /// ```
45    /// use pubmed_client::pubmed::SearchQuery;
46    ///
47    /// let query = SearchQuery::new()
48    ///     .query("covid-19 treatment");
49    /// ```
50    pub fn query<S: Into<String>>(mut self, terms: S) -> Self {
51        self.terms.push(terms.into());
52        self
53    }
54
55    /// Add multiple search terms
56    ///
57    /// # Arguments
58    ///
59    /// * `terms` - Multiple search terms
60    ///
61    /// # Example
62    ///
63    /// ```
64    /// use pubmed_client::pubmed::SearchQuery;
65    ///
66    /// let query = SearchQuery::new()
67    ///     .terms(&["covid-19", "treatment", "vaccine"]);
68    /// ```
69    pub fn terms<S: AsRef<str>>(mut self, terms: &[S]) -> Self {
70        for term in terms {
71            self.terms.push(term.as_ref().to_string());
72        }
73        self
74    }
75
76    /// Set the maximum number of results to return
77    ///
78    /// # Arguments
79    ///
80    /// * `limit` - Maximum number of results
81    ///
82    /// # Example
83    ///
84    /// ```
85    /// use pubmed_client::pubmed::SearchQuery;
86    ///
87    /// let query = SearchQuery::new()
88    ///     .query("cancer")
89    ///     .limit(100);
90    /// ```
91    pub fn limit(mut self, limit: usize) -> Self {
92        self.limit = Some(limit);
93        self
94    }
95
96    /// Build the final query string
97    ///
98    /// # Returns
99    ///
100    /// A PubMed query string that can be used with E-utilities
101    ///
102    /// # Example
103    ///
104    /// ```
105    /// use pubmed_client::pubmed::SearchQuery;
106    ///
107    /// let query_string = SearchQuery::new()
108    ///     .query("covid-19")
109    ///     .build();
110    ///
111    /// assert_eq!(query_string, "covid-19");
112    /// ```
113    pub fn build(&self) -> String {
114        let mut parts = Vec::new();
115
116        // Add search terms
117        if !self.terms.is_empty() {
118            parts.push(self.terms.join(" "));
119        }
120
121        // Add filters
122        parts.extend(self.filters.clone());
123
124        parts.join(" AND ")
125    }
126
127    /// Get the limit for this query
128    pub fn get_limit(&self) -> usize {
129        self.limit.unwrap_or(20)
130    }
131
132    /// Set the sort order for search results
133    ///
134    /// # Arguments
135    ///
136    /// * `sort` - Sort order for the search results
137    ///
138    /// # Example
139    ///
140    /// ```
141    /// use pubmed_client::pubmed::{SearchQuery, SortOrder};
142    ///
143    /// let query = SearchQuery::new()
144    ///     .query("cancer")
145    ///     .sort(SortOrder::PublicationDate);
146    /// ```
147    pub fn sort(mut self, sort: SortOrder) -> Self {
148        self.sort = Some(sort);
149        self
150    }
151
152    /// Get the sort order for this query
153    pub fn get_sort(&self) -> Option<&SortOrder> {
154        self.sort.as_ref()
155    }
156
157    /// Execute the search using the provided PubMed client
158    ///
159    /// # Arguments
160    ///
161    /// * `client` - PubMed client to use for the search
162    ///
163    /// # Returns
164    ///
165    /// Returns a list of PMIDs matching the query
166    ///
167    /// # Example
168    ///
169    /// ```no_run
170    /// use pubmed_client::{PubMedClient, pubmed::SearchQuery};
171    ///
172    /// #[tokio::main]
173    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
174    ///     let client = PubMedClient::new();
175    ///     let pmids = SearchQuery::new()
176    ///         .query("covid-19")
177    ///         .limit(10)
178    ///         .search(&client)
179    ///         .await?;
180    ///
181    ///     println!("Found {} articles", pmids.len());
182    ///     Ok(())
183    /// }
184    /// ```
185    pub async fn search(&self, client: &PubMedClient) -> Result<Vec<String>> {
186        let query_string = self.build();
187        client
188            .search_articles(&query_string, self.get_limit(), self.sort.as_ref())
189            .await
190    }
191
192    /// Execute the search and fetch full article metadata
193    ///
194    /// # Arguments
195    ///
196    /// * `client` - PubMed client to use for the search
197    ///
198    /// # Returns
199    ///
200    /// Returns a list of PubMed articles with metadata
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// use pubmed_client::{PubMedClient, pubmed::SearchQuery};
206    ///
207    /// #[tokio::main]
208    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
209    ///     let client = PubMedClient::new();
210    ///     let articles = SearchQuery::new()
211    ///         .query("machine learning medicine")
212    ///         .limit(5)
213    ///         .search_and_fetch(&client)
214    ///         .await?;
215    ///
216    ///     for article in articles {
217    ///         println!("{}: {}", article.pmid, article.title);
218    ///     }
219    ///     Ok(())
220    /// }
221    /// ```
222    pub async fn search_and_fetch(&self, client: &PubMedClient) -> Result<Vec<PubMedArticle>> {
223        let query_string = self.build();
224        client
225            .search_and_fetch(&query_string, self.get_limit(), self.sort.as_ref())
226            .await
227    }
228
229    /// Execute the search and return detailed results including query translation
230    ///
231    /// This method uses the NCBI history server and returns a `SearchResult` that includes
232    /// the query translation (how PubMed interpreted the query), total count, and session
233    /// information for paginated fetching.
234    ///
235    /// # Arguments
236    ///
237    /// * `client` - PubMed client to use for the search
238    ///
239    /// # Returns
240    ///
241    /// Returns a `SearchResult` with PMIDs, total count, query translation, and history session
242    ///
243    /// # Example
244    ///
245    /// ```no_run
246    /// use pubmed_client::{PubMedClient, pubmed::SearchQuery};
247    ///
248    /// #[tokio::main]
249    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
250    ///     let client = PubMedClient::new();
251    ///     let result = SearchQuery::new()
252    ///         .query("asthma")
253    ///         .limit(10)
254    ///         .search_with_details(&client)
255    ///         .await?;
256    ///
257    ///     println!("Total: {}", result.total_count);
258    ///     if let Some(translation) = &result.query_translation {
259    ///         println!("PubMed interpreted query as: {}", translation);
260    ///     }
261    ///     Ok(())
262    /// }
263    /// ```
264    pub async fn search_with_details(&self, client: &PubMedClient) -> Result<SearchResult> {
265        let query_string = self.build();
266        client
267            .search_with_history_and_options(&query_string, self.get_limit(), self.sort.as_ref())
268            .await
269    }
270}
271
272impl Default for SearchQuery {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_new_query() {
284        let query = SearchQuery::new();
285        assert_eq!(query.build(), "");
286        assert_eq!(query.get_limit(), 20);
287    }
288
289    #[test]
290    fn test_default_query() {
291        let query = SearchQuery::default();
292        assert_eq!(query.build(), "");
293        assert_eq!(query.get_limit(), 20);
294    }
295
296    #[test]
297    fn test_single_query_term() {
298        let query = SearchQuery::new().query("covid-19");
299        assert_eq!(query.build(), "covid-19");
300    }
301
302    #[test]
303    fn test_multiple_query_calls() {
304        let query = SearchQuery::new().query("covid-19").query("treatment");
305        assert_eq!(query.build(), "covid-19 treatment");
306    }
307
308    #[test]
309    fn test_terms_method() {
310        let terms = ["covid-19", "vaccine", "efficacy"];
311        let query = SearchQuery::new().terms(&terms);
312        assert_eq!(query.build(), "covid-19 vaccine efficacy");
313    }
314
315    #[test]
316    fn test_empty_terms_array() {
317        let terms: &[&str] = &[];
318        let query = SearchQuery::new().terms(terms);
319        assert_eq!(query.build(), "");
320    }
321
322    #[test]
323    fn test_limit_setting() {
324        let query = SearchQuery::new().limit(100);
325        assert_eq!(query.get_limit(), 100);
326    }
327
328    #[test]
329    fn test_limit_with_query() {
330        let query = SearchQuery::new().query("cancer").limit(50);
331        assert_eq!(query.build(), "cancer");
332        assert_eq!(query.get_limit(), 50);
333    }
334
335    #[test]
336    fn test_string_and_str_inputs() {
337        let query1 = SearchQuery::new().query("test");
338        let query2 = SearchQuery::new().query("test".to_string());
339        assert_eq!(query1.build(), query2.build());
340    }
341
342    #[test]
343    fn test_empty_query_build() {
344        let query = SearchQuery::new();
345        assert_eq!(query.build(), "");
346    }
347
348    #[test]
349    fn test_terms_and_filters_combined() {
350        let mut query = SearchQuery::new();
351        query.terms.push("cancer".to_string());
352        query.filters.push("test[filter]".to_string());
353        assert_eq!(query.build(), "cancer AND test[filter]");
354    }
355
356    #[test]
357    fn test_only_filters_no_terms() {
358        let mut query = SearchQuery::new();
359        query.filters.push("test1[filter]".to_string());
360        query.filters.push("test2[filter]".to_string());
361        assert_eq!(query.build(), "test1[filter] AND test2[filter]");
362    }
363
364    #[test]
365    fn test_limit_edge_values() {
366        let query = SearchQuery::new().limit(0);
367        assert_eq!(query.get_limit(), 0);
368
369        let query = SearchQuery::new().limit(usize::MAX);
370        assert_eq!(query.get_limit(), usize::MAX);
371    }
372
373    #[test]
374    fn test_chaining_methods() {
375        let query = SearchQuery::new()
376            .query("test")
377            .limit(10)
378            .query("more")
379            .limit(20); // Should override previous limit
380
381        assert_eq!(query.get_limit(), 20);
382        assert_eq!(query.build(), "test more");
383    }
384
385    #[test]
386    fn test_sort_setting() {
387        let query = SearchQuery::new()
388            .query("cancer")
389            .sort(SortOrder::PublicationDate);
390        assert_eq!(query.get_sort(), Some(&SortOrder::PublicationDate));
391    }
392
393    #[test]
394    fn test_sort_default_none() {
395        let query = SearchQuery::new().query("cancer");
396        assert_eq!(query.get_sort(), None);
397    }
398
399    #[test]
400    fn test_sort_override() {
401        let query = SearchQuery::new()
402            .query("cancer")
403            .sort(SortOrder::PublicationDate)
404            .sort(SortOrder::FirstAuthor);
405        assert_eq!(query.get_sort(), Some(&SortOrder::FirstAuthor));
406    }
407
408    #[test]
409    fn test_sort_with_other_options() {
410        let query = SearchQuery::new()
411            .query("cancer")
412            .limit(50)
413            .sort(SortOrder::JournalName);
414        assert_eq!(query.build(), "cancer");
415        assert_eq!(query.get_limit(), 50);
416        assert_eq!(query.get_sort(), Some(&SortOrder::JournalName));
417    }
418
419    mod proptest_suite {
420        use super::*;
421        use proptest::prelude::*;
422
423        proptest! {
424            #[test]
425            fn build_is_deterministic(term in "[a-z]+") {
426                let q = SearchQuery::new().query(term);
427                prop_assert_eq!(q.build(), q.build());
428            }
429
430            #[test]
431            fn limit_does_not_affect_build(term in "[a-z]+", limit in 1usize..=10000usize) {
432                let without_limit = SearchQuery::new().query(term.clone()).build();
433                let with_limit    = SearchQuery::new().query(term).limit(limit).build();
434                prop_assert_eq!(without_limit, with_limit);
435            }
436        }
437    }
438}