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}