pubmed_client/europe_pmc/
search.rs

1//! Europe PMC `search` endpoint operations (cursor-based pagination).
2
3use tracing::{debug, instrument};
4
5use pubmed_parser::europe_pmc::{EuropePmcResult, EuropePmcSearchResponse, parse_search_response};
6
7use crate::error::Result;
8
9use super::client::EuropePmcClient;
10
11/// The level of detail returned by the Europe PMC `search` endpoint.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ResultType {
14    /// Identifiers only.
15    IdList,
16    /// Core bibliographic fields (default).
17    Lite,
18    /// Full metadata including abstracts, MeSH, full author/affiliation data.
19    Core,
20}
21
22impl ResultType {
23    fn as_str(&self) -> &'static str {
24        match self {
25            ResultType::IdList => "idlist",
26            ResultType::Lite => "lite",
27            ResultType::Core => "core",
28        }
29    }
30}
31
32/// Options controlling a single Europe PMC `search` request.
33#[derive(Debug, Clone)]
34pub struct EuropePmcSearchOptions {
35    /// Level of detail to return.
36    pub result_type: ResultType,
37    /// Number of results per page (Europe PMC caps this at 1000).
38    pub page_size: u32,
39    /// Cursor mark for the page to fetch. Use `"*"` for the first page.
40    pub cursor_mark: String,
41    /// Optional sort expression (e.g. `"P_PDATE_D desc"`, `"CITED desc"`).
42    pub sort: Option<String>,
43}
44
45impl Default for EuropePmcSearchOptions {
46    fn default() -> Self {
47        Self {
48            result_type: ResultType::Lite,
49            page_size: 25,
50            cursor_mark: "*".to_string(),
51            sort: None,
52        }
53    }
54}
55
56impl EuropePmcClient {
57    /// Search Europe PMC and return up to `limit` lite results.
58    ///
59    /// Convenience wrapper over [`EuropePmcClient::search_all`] using
60    /// [`ResultType::Lite`]. For cursor control or `core` detail, use
61    /// [`EuropePmcClient::search_page`] / [`EuropePmcClient::search_all`].
62    #[instrument(skip(self), fields(query = %query, limit))]
63    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<EuropePmcResult>> {
64        let opts = EuropePmcSearchOptions {
65            page_size: limit.clamp(1, 1000) as u32,
66            ..Default::default()
67        };
68        self.search_all(query, limit, &opts).await
69    }
70
71    /// Fetch a single page of search results.
72    ///
73    /// The returned [`EuropePmcSearchResponse::next_cursor_mark`] is the cursor
74    /// to pass back via `opts.cursor_mark` to fetch the following page.
75    #[instrument(skip(self, opts), fields(query = %query, cursor = %opts.cursor_mark))]
76    pub async fn search_page(
77        &self,
78        query: &str,
79        opts: &EuropePmcSearchOptions,
80    ) -> Result<EuropePmcSearchResponse> {
81        let page_size = opts.page_size.to_string();
82        let mut params: Vec<(&str, &str)> = vec![
83            ("query", query),
84            ("format", "json"),
85            ("resultType", opts.result_type.as_str()),
86            ("pageSize", page_size.as_str()),
87            ("cursorMark", opts.cursor_mark.as_str()),
88        ];
89        if let Some(sort) = &opts.sort {
90            params.push(("sort", sort.as_str()));
91        }
92
93        let response = self
94            .executor()
95            .get_endpoint(&self.base_url, "search", &params)
96            .await?;
97        let text = response.text().await?;
98        Ok(parse_search_response(&text)?)
99    }
100
101    /// Fetch search results across pages until `max_results` is reached or the
102    /// result set is exhausted.
103    ///
104    /// Follows the `nextCursorMark` chain. Europe PMC signals the end of results
105    /// by returning the same cursor it was given, so the loop also stops when the
106    /// cursor stops advancing.
107    #[instrument(skip(self, opts), fields(query = %query, max_results))]
108    pub async fn search_all(
109        &self,
110        query: &str,
111        max_results: usize,
112        opts: &EuropePmcSearchOptions,
113    ) -> Result<Vec<EuropePmcResult>> {
114        let mut collected: Vec<EuropePmcResult> = Vec::new();
115        let mut cursor = opts.cursor_mark.clone();
116
117        while collected.len() < max_results {
118            let page_opts = EuropePmcSearchOptions {
119                cursor_mark: cursor.clone(),
120                ..opts.clone()
121            };
122            let page = self.search_page(query, &page_opts).await?;
123
124            if page.results.is_empty() {
125                break;
126            }
127            collected.extend(page.results);
128
129            match page.next_cursor_mark {
130                // Cursor stopped advancing => last page reached.
131                Some(next) if next != cursor => cursor = next,
132                _ => break,
133            }
134        }
135
136        collected.truncate(max_results);
137        debug!(returned = collected.len(), "Europe PMC search_all complete");
138        Ok(collected)
139    }
140}