pubmed_client/pubmed/query/
filters.rs

1//! Filter types and enums for PubMed query filtering
2
3/// Validate that a year is within the range valid for biomedical publications (1800–3000).
4///
5/// Returns `Ok(())` if valid, or `Err(String)` with a descriptive message if not.
6/// Bindings convert the error message to their native error type.
7pub fn validate_year(year: u32) -> Result<(), String> {
8    if !(1800..=3000).contains(&year) {
9        Err(format!("Year must be between 1800 and 3000, got: {}", year))
10    } else {
11        Ok(())
12    }
13}
14
15/// Sort order for PubMed search results
16///
17/// Controls how ESearch results are ordered. The default sort (when not specified)
18/// is by relevance for most queries.
19///
20/// See: <https://www.ncbi.nlm.nih.gov/books/NBK25499/#chapter4.ESearch>
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum SortOrder {
23    /// Sort by relevance (default PubMed behavior)
24    Relevance,
25    /// Sort by publication date (newest first)
26    PublicationDate,
27    /// Sort by first author name (alphabetical)
28    FirstAuthor,
29    /// Sort by journal name (alphabetical)
30    JournalName,
31}
32
33impl SortOrder {
34    /// Parse a sort order from a case-insensitive string.
35    ///
36    /// Accepted values: `"relevance"`, `"pub_date"` / `"publication_date"` / `"date"`,
37    /// `"author"` / `"first_author"`, `"journal"` / `"journal_name"`.
38    ///
39    /// Returns `Err(String)` for unrecognised input; bindings convert to their native error type.
40    pub fn from_str_insensitive(s: &str) -> Result<Self, String> {
41        match s.trim().to_lowercase().as_str() {
42            "relevance" => Ok(SortOrder::Relevance),
43            "pub_date" | "publication_date" | "date" => Ok(SortOrder::PublicationDate),
44            "author" | "first_author" => Ok(SortOrder::FirstAuthor),
45            "journal" | "journal_name" => Ok(SortOrder::JournalName),
46            _ => Err(format!(
47                "Invalid sort order: '{}'. Supported values: relevance, pub_date, author, journal",
48                s
49            )),
50        }
51    }
52
53    /// Get the API parameter value for this sort order
54    pub(crate) fn as_api_param(&self) -> &str {
55        match self {
56            SortOrder::Relevance => "relevance",
57            SortOrder::PublicationDate => "pub_date",
58            SortOrder::FirstAuthor => "Author",
59            SortOrder::JournalName => "JournalName",
60        }
61    }
62}
63
64/// Article types that can be filtered in PubMed searches
65#[derive(Debug, Clone, PartialEq)]
66pub enum ArticleType {
67    /// Clinical trials
68    ClinicalTrial,
69    /// Review articles
70    Review,
71    /// Systematic reviews
72    SystematicReview,
73    /// Meta-analysis
74    MetaAnalysis,
75    /// Case reports
76    CaseReport,
77    /// Randomized controlled trials
78    RandomizedControlledTrial,
79    /// Observational studies
80    ObservationalStudy,
81}
82
83impl ArticleType {
84    /// Parse an article type from a case-insensitive string.
85    ///
86    /// Accepted values (case-insensitive): `"Clinical Trial"`, `"Review"`, `"Systematic Review"`,
87    /// `"Meta-Analysis"` / `"Meta Analysis"`, `"Case Reports"` / `"Case Report"`,
88    /// `"Randomized Controlled Trial"` / `"RCT"`, `"Observational Study"`.
89    ///
90    /// Returns `Err(String)` for unrecognised input; bindings convert to their native error type.
91    pub fn from_str_insensitive(s: &str) -> Result<Self, String> {
92        match s.trim().to_lowercase().as_str() {
93            "clinical trial" => Ok(ArticleType::ClinicalTrial),
94            "review" => Ok(ArticleType::Review),
95            "systematic review" => Ok(ArticleType::SystematicReview),
96            "meta-analysis" | "meta analysis" => Ok(ArticleType::MetaAnalysis),
97            "case reports" | "case report" => Ok(ArticleType::CaseReport),
98            "randomized controlled trial" | "rct" => Ok(ArticleType::RandomizedControlledTrial),
99            "observational study" => Ok(ArticleType::ObservationalStudy),
100            _ => Err(format!(
101                "Invalid article type: '{}'. Supported types: Clinical Trial, Review, Systematic Review, Meta-Analysis, Case Reports, Randomized Controlled Trial, Observational Study",
102                s
103            )),
104        }
105    }
106
107    pub(crate) fn to_query_string(&self) -> &'static str {
108        match self {
109            ArticleType::ClinicalTrial => "Clinical Trial[pt]",
110            ArticleType::Review => "Review[pt]",
111            ArticleType::SystematicReview => "Systematic Review[pt]",
112            ArticleType::MetaAnalysis => "Meta-Analysis[pt]",
113            ArticleType::CaseReport => "Case Reports[pt]",
114            ArticleType::RandomizedControlledTrial => "Randomized Controlled Trial[pt]",
115            ArticleType::ObservationalStudy => "Observational Study[pt]",
116        }
117    }
118}
119
120/// Language options for filtering articles
121#[derive(Debug, Clone, PartialEq)]
122pub enum Language {
123    English,
124    Japanese,
125    German,
126    French,
127    Spanish,
128    Italian,
129    Chinese,
130    Russian,
131    Portuguese,
132    Arabic,
133    Dutch,
134    Korean,
135    Polish,
136    Swedish,
137    Danish,
138    Norwegian,
139    Finnish,
140    Turkish,
141    Hebrew,
142    Czech,
143    Hungarian,
144    Greek,
145    Other(String),
146}
147
148impl Language {
149    /// Parse a language from a case-insensitive string.
150    ///
151    /// Accepts full English names (`"english"`, `"japanese"`, …) and ISO 639-2 three-letter codes
152    /// (`"eng"`, `"jpn"`, …). Unrecognised values fall back to `Language::Other(s)` rather than
153    /// returning an error, so callers never need to handle the unknown-language case.
154    pub fn from_str_insensitive(s: &str) -> Self {
155        match s.trim().to_lowercase().as_str() {
156            "english" | "eng" => Language::English,
157            "japanese" | "jpn" => Language::Japanese,
158            "german" | "ger" | "deu" => Language::German,
159            "french" | "fre" | "fra" => Language::French,
160            "spanish" | "spa" => Language::Spanish,
161            "italian" | "ita" => Language::Italian,
162            "chinese" | "chi" | "zho" => Language::Chinese,
163            "russian" | "rus" => Language::Russian,
164            "portuguese" | "por" => Language::Portuguese,
165            "arabic" | "ara" => Language::Arabic,
166            "dutch" | "dut" | "nld" => Language::Dutch,
167            "korean" | "kor" => Language::Korean,
168            "polish" | "pol" => Language::Polish,
169            "swedish" | "swe" => Language::Swedish,
170            "danish" | "dan" => Language::Danish,
171            "norwegian" | "nor" => Language::Norwegian,
172            "finnish" | "fin" => Language::Finnish,
173            "turkish" | "tur" => Language::Turkish,
174            "hebrew" | "heb" => Language::Hebrew,
175            "czech" | "cze" | "ces" => Language::Czech,
176            "hungarian" | "hun" => Language::Hungarian,
177            "greek" | "gre" | "ell" => Language::Greek,
178            _ => Language::Other(s.trim().to_string()),
179        }
180    }
181
182    pub(crate) fn to_query_string(&self) -> String {
183        match self {
184            Language::English => "English[la]".to_string(),
185            Language::Japanese => "Japanese[la]".to_string(),
186            Language::German => "German[la]".to_string(),
187            Language::French => "French[la]".to_string(),
188            Language::Spanish => "Spanish[la]".to_string(),
189            Language::Italian => "Italian[la]".to_string(),
190            Language::Chinese => "Chinese[la]".to_string(),
191            Language::Russian => "Russian[la]".to_string(),
192            Language::Portuguese => "Portuguese[la]".to_string(),
193            Language::Arabic => "Arabic[la]".to_string(),
194            Language::Dutch => "Dutch[la]".to_string(),
195            Language::Korean => "Korean[la]".to_string(),
196            Language::Polish => "Polish[la]".to_string(),
197            Language::Swedish => "Swedish[la]".to_string(),
198            Language::Danish => "Danish[la]".to_string(),
199            Language::Norwegian => "Norwegian[la]".to_string(),
200            Language::Finnish => "Finnish[la]".to_string(),
201            Language::Turkish => "Turkish[la]".to_string(),
202            Language::Hebrew => "Hebrew[la]".to_string(),
203            Language::Czech => "Czech[la]".to_string(),
204            Language::Hungarian => "Hungarian[la]".to_string(),
205            Language::Greek => "Greek[la]".to_string(),
206            Language::Other(lang) => format!("{lang}[la]"),
207        }
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn test_article_type_to_query_string() {
217        let test_cases = vec![
218            (ArticleType::ClinicalTrial, "Clinical Trial[pt]"),
219            (ArticleType::Review, "Review[pt]"),
220            (ArticleType::SystematicReview, "Systematic Review[pt]"),
221            (ArticleType::MetaAnalysis, "Meta-Analysis[pt]"),
222            (ArticleType::CaseReport, "Case Reports[pt]"),
223            (
224                ArticleType::RandomizedControlledTrial,
225                "Randomized Controlled Trial[pt]",
226            ),
227            (ArticleType::ObservationalStudy, "Observational Study[pt]"),
228        ];
229
230        for (article_type, expected) in test_cases {
231            assert_eq!(article_type.to_query_string(), expected);
232        }
233    }
234
235    #[test]
236    fn test_language_to_query_string() {
237        let test_cases = vec![
238            (Language::English, "English[la]"),
239            (Language::Japanese, "Japanese[la]"),
240            (Language::German, "German[la]"),
241            (Language::French, "French[la]"),
242            (Language::Spanish, "Spanish[la]"),
243            (Language::Italian, "Italian[la]"),
244            (Language::Chinese, "Chinese[la]"),
245            (Language::Russian, "Russian[la]"),
246            (Language::Portuguese, "Portuguese[la]"),
247            (Language::Arabic, "Arabic[la]"),
248            (Language::Dutch, "Dutch[la]"),
249            (Language::Korean, "Korean[la]"),
250            (Language::Polish, "Polish[la]"),
251            (Language::Swedish, "Swedish[la]"),
252            (Language::Danish, "Danish[la]"),
253            (Language::Norwegian, "Norwegian[la]"),
254            (Language::Finnish, "Finnish[la]"),
255            (Language::Turkish, "Turkish[la]"),
256            (Language::Hebrew, "Hebrew[la]"),
257            (Language::Czech, "Czech[la]"),
258            (Language::Hungarian, "Hungarian[la]"),
259            (Language::Greek, "Greek[la]"),
260        ];
261
262        for (language, expected) in test_cases {
263            assert_eq!(language.to_query_string(), expected);
264        }
265    }
266
267    #[test]
268    fn test_language_other_variant() {
269        let custom_lang = Language::Other("Esperanto".to_string());
270        assert_eq!(custom_lang.to_query_string(), "Esperanto[la]");
271
272        let another_custom = Language::Other("Klingon".to_string());
273        assert_eq!(another_custom.to_query_string(), "Klingon[la]");
274    }
275
276    #[test]
277    fn test_article_type_equality() {
278        assert_eq!(ArticleType::Review, ArticleType::Review);
279        assert_ne!(ArticleType::Review, ArticleType::ClinicalTrial);
280        assert_ne!(ArticleType::MetaAnalysis, ArticleType::SystematicReview);
281    }
282
283    #[test]
284    fn test_language_equality() {
285        assert_eq!(Language::English, Language::English);
286        assert_ne!(Language::English, Language::Japanese);
287
288        let other1 = Language::Other("Custom".to_string());
289        let other2 = Language::Other("Custom".to_string());
290        let other3 = Language::Other("Different".to_string());
291
292        assert_eq!(other1, other2);
293        assert_ne!(other1, other3);
294        assert_ne!(Language::English, other1);
295    }
296
297    #[test]
298    fn test_debug_formatting() {
299        let article_type = ArticleType::Review;
300        let debug_str = format!("{:?}", article_type);
301        assert!(debug_str.contains("Review"));
302
303        let language = Language::English;
304        let debug_str = format!("{:?}", language);
305        assert!(debug_str.contains("English"));
306
307        let custom_lang = Language::Other("Test".to_string());
308        let debug_str = format!("{:?}", custom_lang);
309        assert!(debug_str.contains("Other"));
310        assert!(debug_str.contains("Test"));
311    }
312
313    #[test]
314    fn test_clone_functionality() {
315        let original_type = ArticleType::MetaAnalysis;
316        let cloned_type = original_type.clone();
317        assert_eq!(original_type, cloned_type);
318        assert_eq!(
319            original_type.to_query_string(),
320            cloned_type.to_query_string()
321        );
322
323        let original_lang = Language::German;
324        let cloned_lang = original_lang.clone();
325        assert_eq!(original_lang, cloned_lang);
326        assert_eq!(
327            original_lang.to_query_string(),
328            cloned_lang.to_query_string()
329        );
330
331        let original_other = Language::Other("Custom".to_string());
332        let cloned_other = original_other.clone();
333        assert_eq!(original_other, cloned_other);
334        assert_eq!(
335            original_other.to_query_string(),
336            cloned_other.to_query_string()
337        );
338    }
339
340    #[test]
341    fn test_language_other_empty_string() {
342        let empty_lang = Language::Other("".to_string());
343        assert_eq!(empty_lang.to_query_string(), "[la]");
344    }
345
346    #[test]
347    fn test_language_other_special_characters() {
348        let special_lang = Language::Other("中文-汉语".to_string());
349        assert_eq!(special_lang.to_query_string(), "中文-汉语[la]");
350
351        let symbol_lang = Language::Other("Lang@#$%".to_string());
352        assert_eq!(symbol_lang.to_query_string(), "Lang@#$%[la]");
353    }
354
355    #[test]
356    fn test_all_article_types_unique() {
357        let all_types = vec![
358            ArticleType::ClinicalTrial,
359            ArticleType::Review,
360            ArticleType::SystematicReview,
361            ArticleType::MetaAnalysis,
362            ArticleType::CaseReport,
363            ArticleType::RandomizedControlledTrial,
364            ArticleType::ObservationalStudy,
365        ];
366
367        let mut query_strings = Vec::new();
368        for article_type in all_types {
369            let query_string = article_type.to_query_string();
370            assert!(
371                !query_strings.contains(&query_string),
372                "Duplicate query string found: {}",
373                query_string
374            );
375            query_strings.push(query_string);
376        }
377    }
378
379    #[test]
380    fn test_sort_order_as_api_param() {
381        assert_eq!(SortOrder::Relevance.as_api_param(), "relevance");
382        assert_eq!(SortOrder::PublicationDate.as_api_param(), "pub_date");
383        assert_eq!(SortOrder::FirstAuthor.as_api_param(), "Author");
384        assert_eq!(SortOrder::JournalName.as_api_param(), "JournalName");
385    }
386
387    #[test]
388    fn test_sort_order_equality() {
389        assert_eq!(SortOrder::Relevance, SortOrder::Relevance);
390        assert_ne!(SortOrder::Relevance, SortOrder::PublicationDate);
391        assert_ne!(SortOrder::FirstAuthor, SortOrder::JournalName);
392    }
393
394    #[test]
395    fn test_sort_order_clone() {
396        let original = SortOrder::PublicationDate;
397        let cloned = original.clone();
398        assert_eq!(original, cloned);
399        assert_eq!(original.as_api_param(), cloned.as_api_param());
400    }
401
402    #[test]
403    fn test_all_standard_languages_unique() {
404        let standard_languages = vec![
405            Language::English,
406            Language::Japanese,
407            Language::German,
408            Language::French,
409            Language::Spanish,
410            Language::Italian,
411            Language::Chinese,
412            Language::Russian,
413            Language::Portuguese,
414            Language::Arabic,
415            Language::Dutch,
416            Language::Korean,
417            Language::Polish,
418            Language::Swedish,
419            Language::Danish,
420            Language::Norwegian,
421            Language::Finnish,
422            Language::Turkish,
423            Language::Hebrew,
424            Language::Czech,
425            Language::Hungarian,
426            Language::Greek,
427        ];
428
429        let mut query_strings = Vec::new();
430        for language in standard_languages {
431            let query_string = language.to_query_string();
432            assert!(
433                !query_strings.contains(&query_string),
434                "Duplicate query string found: {}",
435                query_string
436            );
437            query_strings.push(query_string);
438        }
439    }
440}