1use 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 #[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 #[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 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 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 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 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 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}