pubmed_client/pubmed/client/
summary.rs

1//! ESummary API operations for fetching lightweight article metadata
2
3use crate::error::{ParseError, PubMedError, Result};
4use crate::pubmed::models::ArticleSummary;
5use crate::pubmed::query::SortOrder;
6use crate::pubmed::responses::{ESummaryDocSum, ESummaryResponse};
7use tracing::{instrument, warn};
8
9use super::PubMedClient;
10
11impl PubMedClient {
12    /// Fetch lightweight article summaries by PMIDs using the ESummary API
13    ///
14    /// Returns basic metadata (title, authors, journal, dates, DOI) without
15    /// abstracts, MeSH terms, or chemical lists. Faster than `fetch_articles()`
16    /// when you only need bibliographic overview data.
17    ///
18    /// # Arguments
19    ///
20    /// * `pmids` - Slice of PubMed IDs as strings
21    ///
22    /// # Returns
23    ///
24    /// Returns a `Result<Vec<ArticleSummary>>` containing lightweight article metadata
25    ///
26    /// # Example
27    ///
28    /// ```no_run
29    /// use pubmed_client::PubMedClient;
30    ///
31    /// #[tokio::main]
32    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
33    ///     let client = PubMedClient::new();
34    ///     let summaries = client.fetch_summaries(&["31978945", "33515491"]).await?;
35    ///     for summary in &summaries {
36    ///         println!("{}: {} ({})", summary.pmid, summary.title, summary.pub_date);
37    ///     }
38    ///     Ok(())
39    /// }
40    /// ```
41    #[instrument(skip(self), fields(pmids_count = pmids.len()))]
42    pub async fn fetch_summaries(&self, pmids: &[&str]) -> Result<Vec<ArticleSummary>> {
43        self.batch_fetch_pmids(
44            pmids,
45            "esummary.fcgi",
46            &[("retmode", "json")],
47            Self::parse_esummary_response,
48        )
49        .await
50    }
51
52    /// Fetch a single article summary by PMID using the ESummary API
53    ///
54    /// # Arguments
55    ///
56    /// * `pmid` - PubMed ID as a string
57    ///
58    /// # Returns
59    ///
60    /// Returns a `Result<ArticleSummary>` containing lightweight article metadata
61    ///
62    /// # Example
63    ///
64    /// ```no_run
65    /// use pubmed_client::PubMedClient;
66    ///
67    /// #[tokio::main]
68    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
69    ///     let client = PubMedClient::new();
70    ///     let summary = client.fetch_summary("31978945").await?;
71    ///     println!("{}: {}", summary.pmid, summary.title);
72    ///     Ok(())
73    /// }
74    /// ```
75    #[instrument(skip(self), fields(pmid = %pmid))]
76    pub async fn fetch_summary(&self, pmid: &str) -> Result<ArticleSummary> {
77        let mut summaries = self.fetch_summaries(&[pmid]).await?;
78
79        if summaries.len() == 1 {
80            Ok(summaries.remove(0))
81        } else {
82            let idx = summaries.iter().position(|s| s.pmid == pmid);
83            match idx {
84                Some(i) => Ok(summaries.remove(i)),
85                None => Err(ParseError::ArticleNotFound {
86                    pmid: pmid.to_string(),
87                }
88                .into()),
89            }
90        }
91    }
92
93    /// Search and fetch lightweight summaries in a single operation
94    ///
95    /// Combines `search_articles()` and `fetch_summaries()`. Use this when you
96    /// only need basic metadata (title, authors, journal, dates) and want faster
97    /// retrieval than `search_and_fetch()`.
98    ///
99    /// # Arguments
100    ///
101    /// * `query` - Search query string
102    /// * `limit` - Maximum number of articles to fetch
103    ///
104    /// # Returns
105    ///
106    /// Returns a `Result<Vec<ArticleSummary>>` containing lightweight article metadata
107    ///
108    /// # Example
109    ///
110    /// ```no_run
111    /// use pubmed_client::PubMedClient;
112    ///
113    /// #[tokio::main]
114    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
115    ///     let client = PubMedClient::new();
116    ///     let summaries = client.search_and_fetch_summaries("covid-19 treatment", 20, None).await?;
117    ///     for summary in &summaries {
118    ///         println!("{}: {}", summary.pmid, summary.title);
119    ///     }
120    ///     Ok(())
121    /// }
122    /// ```
123    pub async fn search_and_fetch_summaries(
124        &self,
125        query: &str,
126        limit: usize,
127        sort: Option<&SortOrder>,
128    ) -> Result<Vec<ArticleSummary>> {
129        let pmids = self.search_articles(query, limit, sort).await?;
130
131        let pmid_refs: Vec<&str> = pmids.iter().map(|s| s.as_str()).collect();
132        self.fetch_summaries(&pmid_refs).await
133    }
134
135    /// Parse ESummary JSON response into ArticleSummary objects
136    pub(crate) fn parse_esummary_response(json_text: &str) -> Result<Vec<ArticleSummary>> {
137        let response: ESummaryResponse =
138            serde_json::from_str(json_text).map_err(|e| PubMedError::from(ParseError::from(e)))?;
139
140        let result = &response.result;
141
142        // Get the list of UIDs
143        let uids = result
144            .get("uids")
145            .and_then(|v| v.as_array())
146            .map(|arr| {
147                arr.iter()
148                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
149                    .collect::<Vec<_>>()
150            })
151            .unwrap_or_default();
152
153        let mut summaries = Vec::with_capacity(uids.len());
154
155        for uid in &uids {
156            let Some(doc_value) = result.get(uid) else {
157                warn!(uid = %uid, "UID not found in ESummary response");
158                continue;
159            };
160
161            // Check for error in individual document
162            if doc_value.get("error").is_some() {
163                warn!(uid = %uid, "ESummary returned error for UID");
164                continue;
165            }
166
167            let doc: ESummaryDocSum = match serde_json::from_value(doc_value.clone()) {
168                Ok(d) => d,
169                Err(e) => {
170                    warn!(uid = %uid, error = %e, "Failed to parse ESummary document");
171                    continue;
172                }
173            };
174
175            // Extract DOI and PMC ID from articleids
176            let mut doi = None;
177            let mut pmc_id = None;
178            for aid in &doc.articleids {
179                match aid.idtype.as_str() {
180                    "doi" => {
181                        if !aid.value.is_empty() {
182                            doi = Some(aid.value.clone());
183                        }
184                    }
185                    "pmc" => {
186                        if !aid.value.is_empty() {
187                            pmc_id = Some(aid.value.clone());
188                        }
189                    }
190                    _ => {}
191                }
192            }
193
194            let author_names: Vec<String> = doc.authors.iter().map(|a| a.name.clone()).collect();
195
196            summaries.push(ArticleSummary {
197                pmid: doc.uid,
198                title: doc.title,
199                authors: author_names,
200                journal: doc.source,
201                full_journal_name: doc.fulljournalname,
202                pub_date: doc.pubdate,
203                epub_date: doc.epubdate,
204                doi,
205                pmc_id,
206                volume: doc.volume,
207                issue: doc.issue,
208                pages: doc.pages,
209                languages: doc.lang,
210                pub_types: doc.pubtype,
211                issn: doc.issn,
212                essn: doc.essn,
213                sort_pub_date: doc.sortpubdate,
214                pmc_ref_count: doc.pmcrefcount,
215                record_status: doc.recordstatus,
216            });
217        }
218
219        Ok(summaries)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn test_parse_esummary_response_basic() {
229        let json = r#"{"result":{"uids":["31978945"],"31978945":{"uid":"31978945","pubdate":"2020 Feb","epubdate":"2020 Jan 24","source":"N Engl J Med","authors":[{"name":"Zhu N","authtype":"Author","clusterid":""},{"name":"Zhang D","authtype":"Author","clusterid":""}],"title":"A Novel Coronavirus from Patients with Pneumonia in China, 2019.","sorttitle":"novel coronavirus","volume":"382","issue":"8","pages":"727-733","lang":["eng"],"issn":"0028-4793","essn":"1533-4406","pubtype":["Journal Article"],"articleids":[{"idtype":"pubmed","idtypen":1,"value":"31978945"},{"idtype":"doi","idtypen":3,"value":"10.1056/NEJMoa2001017"},{"idtype":"pmc","idtypen":8,"value":"PMC7092803"}],"fulljournalname":"The New England journal of medicine","sortpubdate":"2020/02/20 00:00","pmcrefcount":14123,"recordstatus":"PubMed - indexed for MEDLINE"}}}"#;
230
231        let summaries = PubMedClient::parse_esummary_response(json).unwrap();
232        assert_eq!(summaries.len(), 1);
233
234        let s = &summaries[0];
235        assert_eq!(s.pmid, "31978945");
236        assert_eq!(
237            s.title,
238            "A Novel Coronavirus from Patients with Pneumonia in China, 2019."
239        );
240        assert_eq!(s.authors, vec!["Zhu N", "Zhang D"]);
241        assert_eq!(s.journal, "N Engl J Med");
242        assert_eq!(s.full_journal_name, "The New England journal of medicine");
243        assert_eq!(s.pub_date, "2020 Feb");
244        assert_eq!(s.epub_date, "2020 Jan 24");
245        assert_eq!(s.doi.as_deref(), Some("10.1056/NEJMoa2001017"));
246        assert_eq!(s.pmc_id.as_deref(), Some("PMC7092803"));
247        assert_eq!(s.volume, "382");
248        assert_eq!(s.issue, "8");
249        assert_eq!(s.pages, "727-733");
250        assert_eq!(s.languages, vec!["eng"]);
251        assert_eq!(s.pub_types, vec!["Journal Article"]);
252        assert_eq!(s.issn, "0028-4793");
253        assert_eq!(s.essn, "1533-4406");
254        assert_eq!(s.sort_pub_date, "2020/02/20 00:00");
255        assert_eq!(s.pmc_ref_count, 14123);
256        assert_eq!(s.record_status, "PubMed - indexed for MEDLINE");
257    }
258
259    #[test]
260    fn test_parse_esummary_response_multiple_uids() {
261        let json = r#"{"result":{"uids":["31978945","33515491"],"31978945":{"uid":"31978945","pubdate":"2020 Feb","epubdate":"","source":"N Engl J Med","authors":[{"name":"Zhu N","authtype":"Author","clusterid":""}],"title":"Article One","volume":"382","issue":"8","pages":"727-733","lang":["eng"],"issn":"","essn":"","pubtype":[],"articleids":[],"fulljournalname":"N Engl J Med","sortpubdate":"","pmcrefcount":0,"recordstatus":""},"33515491":{"uid":"33515491","pubdate":"2021 Jan","epubdate":"","source":"Science","authors":[{"name":"Smith J","authtype":"Author","clusterid":""}],"title":"Article Two","volume":"371","issue":"6526","pages":"120-125","lang":["eng"],"issn":"","essn":"","pubtype":[],"articleids":[{"idtype":"doi","idtypen":3,"value":"10.1126/science.abc123"}],"fulljournalname":"Science","sortpubdate":"","pmcrefcount":100,"recordstatus":""}}}"#;
262
263        let summaries = PubMedClient::parse_esummary_response(json).unwrap();
264        assert_eq!(summaries.len(), 2);
265        assert_eq!(summaries[0].pmid, "31978945");
266        assert_eq!(summaries[0].title, "Article One");
267        assert_eq!(summaries[1].pmid, "33515491");
268        assert_eq!(summaries[1].title, "Article Two");
269        assert_eq!(summaries[1].doi.as_deref(), Some("10.1126/science.abc123"));
270    }
271
272    #[test]
273    fn test_parse_esummary_response_empty() {
274        let json = r#"{"result": {"uids": []}}"#;
275        let summaries = PubMedClient::parse_esummary_response(json).unwrap();
276        assert!(summaries.is_empty());
277    }
278
279    #[test]
280    fn test_parse_esummary_response_with_error_uid() {
281        let json = r#"{"result":{"uids":["99999999999"],"99999999999":{"uid":"99999999999","error":"cannot get document summary"}}}"#;
282
283        let summaries = PubMedClient::parse_esummary_response(json).unwrap();
284        assert!(summaries.is_empty());
285    }
286
287    #[test]
288    fn test_parse_esummary_response_no_doi_no_pmc() {
289        let json = r#"{"result":{"uids":["12345678"],"12345678":{"uid":"12345678","pubdate":"2020","epubdate":"","source":"Some Journal","authors":[],"title":"Test Article","volume":"","issue":"","pages":"","lang":[],"issn":"","essn":"","pubtype":[],"articleids":[{"idtype":"pubmed","idtypen":1,"value":"12345678"}],"fulljournalname":"Some Journal","sortpubdate":"","pmcrefcount":0,"recordstatus":""}}}"#;
290
291        let summaries = PubMedClient::parse_esummary_response(json).unwrap();
292        assert_eq!(summaries.len(), 1);
293        assert!(summaries[0].doi.is_none());
294        assert!(summaries[0].pmc_id.is_none());
295        assert!(summaries[0].authors.is_empty());
296    }
297
298    #[tokio::test]
299    async fn test_fetch_summaries_empty_input() {
300        let client = PubMedClient::new();
301        let result = client.fetch_summaries(&[]).await;
302        assert!(result.is_ok());
303        assert!(
304            result
305                .expect("empty input should return empty summaries")
306                .is_empty()
307        );
308    }
309
310    #[tokio::test]
311    async fn test_fetch_summaries_invalid_pmid() {
312        let client = PubMedClient::new();
313        let result = client.fetch_summaries(&["not_a_number"]).await;
314        assert!(result.is_err());
315    }
316}