1use quick_xml::events::Event;
2use quick_xml::Reader;
3
4use super::Paper;
5
6const ESEARCH_URL: &str = "/entrez/eutils/esearch.fcgi";
7const EFETCH_URL: &str = "/entrez/eutils/efetch.fcgi";
8
9pub fn search(base_url: &str, query: &str, max_results: u32) -> Result<Vec<Paper>, String> {
11 let api_key = std::env::var("NCBI_API_KEY").ok();
12
13 let encoded = super::encode_query(query);
15 let mut esearch_url = format!(
16 "{}{}?db=pmc&term={}&retmax={}&retmode=json&tool=fastpaper&email=yee.zhang@gmail.com",
17 base_url, ESEARCH_URL, encoded, max_results
18 );
19 if let Some(ref key) = api_key {
20 esearch_url.push_str(&format!("&api_key={}", key));
21 }
22
23 let esearch_body = http_get(&esearch_url)?;
24 let esearch_json: serde_json::Value =
25 serde_json::from_str(&esearch_body).map_err(|e| format!("JSON parse error: {}", e))?;
26
27 let ids: Vec<&str> = esearch_json["esearchresult"]["idlist"]
28 .as_array()
29 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
30 .unwrap_or_default();
31
32 if ids.is_empty() {
33 return Ok(vec![]);
34 }
35
36 let mut efetch_url = format!(
38 "{}{}?db=pmc&id={}&rettype=xml&tool=fastpaper&email=yee.zhang@gmail.com",
39 base_url,
40 EFETCH_URL,
41 ids.join(",")
42 );
43 if let Some(ref key) = api_key {
44 efetch_url.push_str(&format!("&api_key={}", key));
45 }
46
47 let efetch_body = http_get(&efetch_url)?;
48 parse_efetch_response(&efetch_body)
49}
50
51fn http_get(url: &str) -> Result<String, String> {
52 let mut last_err = String::new();
53 for attempt in 0..3 {
54 if attempt > 0 {
55 std::thread::sleep(std::time::Duration::from_millis(100 * (1 << attempt)));
56 }
57 match ureq::get(url).call() {
58 Ok(resp) => {
59 return resp
60 .into_body()
61 .read_to_string()
62 .map_err(|e| format!("Failed to read response: {}", e));
63 }
64 Err(ureq::Error::StatusCode(429)) => {
65 last_err = "rate limited (429)".to_string();
66 continue;
67 }
68 Err(ureq::Error::StatusCode(code)) if code >= 500 => {
69 return Err(format!("Server error: {}", code));
70 }
71 Err(e) => {
72 return Err(format!("HTTP error: {}", e));
73 }
74 }
75 }
76 Err(last_err)
77}
78
79pub fn get_by_pmc_id(base_url: &str, pmc_id: &str) -> Result<Option<Paper>, String> {
81 let numeric_id = pmc_id.strip_prefix("PMC").unwrap_or(pmc_id);
83 let url = format!(
84 "{}/entrez/eutils/efetch.fcgi?db=pmc&id={}&rettype=xml&tool=fastpaper&email=yee.zhang@gmail.com",
85 base_url, numeric_id
86 );
87 let body = http_get(&url)?;
88 let papers = parse_efetch_response(&body)?;
89 Ok(papers.into_iter().next())
90}
91
92pub fn parse_efetch_response(xml: &str) -> Result<Vec<Paper>, String> {
94 let mut reader = Reader::from_str(xml);
95 let mut buf = Vec::new();
96 let mut papers = Vec::new();
97
98 let mut in_article = false;
99 let mut in_front = false;
100 let mut in_contrib_author = false;
101 let mut tag_stack: Vec<String> = Vec::new();
102
103 let mut pmcid = String::new();
104 let mut doi = String::new();
105 let mut title = String::new();
106 let mut authors: Vec<String> = Vec::new();
107 let mut surname = String::new();
108 let mut given_names = String::new();
109 let mut abstract_text = String::new();
110 let mut year = String::new();
111 let mut journal = String::new();
112 let mut in_abstract = false;
113
114 loop {
115 match reader.read_event_into(&mut buf) {
116 Ok(Event::Start(ref e)) => {
117 let name = e.name();
118 let local = local_name(name.as_ref());
119 match local {
120 "article" => {
121 in_article = true;
122 pmcid.clear();
123 doi.clear();
124 title.clear();
125 authors.clear();
126 abstract_text.clear();
127 year.clear();
128 journal.clear();
129 }
130 "front" if in_article => in_front = true,
131 "article-id" if in_front => {
132 let mut id_type = String::new();
133 for attr in e.attributes().flatten() {
134 if attr.key.as_ref() == b"pub-id-type" {
135 id_type = String::from_utf8_lossy(&attr.value).to_string();
136 }
137 }
138 tag_stack.push(format!("article-id:{}", id_type));
139 buf.clear();
140 continue;
141 }
142 "contrib" if in_front => {
143 for attr in e.attributes().flatten() {
144 if attr.key.as_ref() == b"contrib-type"
145 && attr.value.as_ref() == b"author"
146 {
147 in_contrib_author = true;
148 surname.clear();
149 given_names.clear();
150 }
151 }
152 }
153 "abstract" if in_front => in_abstract = true,
154 _ => {}
155 }
156 tag_stack.push(local.to_string());
157 }
158 Ok(Event::Text(ref e)) => {
159 if !in_article {
160 buf.clear();
161 continue;
162 }
163 let text = e.decode().unwrap_or_default().to_string();
164 let current = tag_stack.last().map(|s| s.as_str()).unwrap_or("");
165 if current == "article-id:pmcid" && pmcid.is_empty() {
166 pmcid.push_str(text.trim());
167 } else if current == "article-id:doi" && doi.is_empty() {
168 doi.push_str(text.trim());
169 } else if current == "article-title" && in_front {
170 title.push_str(&text);
171 } else if current == "surname" && in_contrib_author {
172 surname.push_str(text.trim());
173 } else if current == "given-names" && in_contrib_author {
174 given_names.push_str(text.trim());
175 } else if in_abstract && !text.trim().is_empty() {
176 if !abstract_text.is_empty() {
177 abstract_text.push(' ');
178 }
179 abstract_text.push_str(text.trim());
180 } else if current == "year" && in_front && year.is_empty() {
181 year.push_str(text.trim());
182 } else if current == "journal-title" && in_front && journal.is_empty() {
183 journal.push_str(text.trim());
184 }
185 }
186 Ok(Event::End(ref e)) => {
187 let name = e.name();
188 let local = local_name(name.as_ref());
189 match local {
190 "article" => {
191 if !pmcid.is_empty() && !title.is_empty() {
192 papers.push(Paper {
193 id: pmcid.clone(),
194 title: title.trim().to_string(),
195 authors: authors.clone(),
196 abstract_text: if abstract_text.is_empty() {
197 None
198 } else {
199 Some(abstract_text.clone())
200 },
201 year: year.parse::<u16>().ok(),
202 doi: if doi.is_empty() { None } else { Some(doi.clone()) },
203 url: Some(format!(
204 "https://www.ncbi.nlm.nih.gov/pmc/articles/{}/",
205 pmcid
206 )),
207 pdf_url: Some(format!(
208 "https://www.ncbi.nlm.nih.gov/pmc/articles/{}/pdf/",
209 pmcid
210 )),
211 venue: if journal.is_empty() {
212 None
213 } else {
214 Some(journal.clone())
215 },
216 citations: None,
217 fields: vec![],
218 open_access: Some(true),
219 source: "pmc".to_string(),
220 });
221 }
222 in_article = false;
223 in_front = false;
224 }
225 "front" => in_front = false,
226 "contrib" if in_contrib_author => {
227 let name_str =
228 format!("{} {}", given_names, surname).trim().to_string();
229 if !name_str.is_empty() {
230 authors.push(name_str);
231 }
232 in_contrib_author = false;
233 }
234 "abstract" => in_abstract = false,
235 _ => {}
236 }
237 tag_stack.pop();
238 }
239 Ok(Event::Eof) => break,
240 Err(e) => return Err(format!("XML parse error: {}", e)),
241 _ => {}
242 }
243 buf.clear();
244 }
245
246 Ok(papers)
247}
248
249fn local_name(name: &[u8]) -> &str {
250 let s = std::str::from_utf8(name).unwrap_or("");
251 s.rsplit(':').next().unwrap_or(s)
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use serial_test::serial;
258
259 const FIXTURE: &str = include_str!("../../tests/fixtures/pmc_efetch.xml");
260
261 #[test]
262 fn parse_returns_ok() {
263 let result = parse_efetch_response(FIXTURE);
264 assert!(result.is_ok());
265 }
266
267 #[test]
268 fn parse_papers_not_empty() {
269 let papers = parse_efetch_response(FIXTURE).unwrap();
270 assert!(!papers.is_empty());
271 }
272
273 #[test]
274 fn parse_source_is_pmc() {
275 let papers = parse_efetch_response(FIXTURE).unwrap();
276 for p in &papers {
277 assert_eq!(p.source, "pmc");
278 }
279 }
280
281 #[test]
282 fn parse_id_is_pmc_format() {
283 let papers = parse_efetch_response(FIXTURE).unwrap();
284 for p in &papers {
285 assert!(p.id.starts_with("PMC"), "id should start with PMC: {}", p.id);
286 }
287 }
288
289 #[test]
290 fn parse_title_not_empty() {
291 let papers = parse_efetch_response(FIXTURE).unwrap();
292 for p in &papers {
293 assert!(!p.title.is_empty(), "paper {} has empty title", p.id);
294 }
295 }
296
297 #[test]
298 fn parse_authors_surname_given() {
299 let papers = parse_efetch_response(FIXTURE).unwrap();
300 let with_authors: Vec<_> = papers.iter().filter(|p| !p.authors.is_empty()).collect();
301 assert!(!with_authors.is_empty(), "no papers with authors");
302 }
303
304 #[test]
305 fn parse_doi() {
306 let papers = parse_efetch_response(FIXTURE).unwrap();
307 let with_doi: Vec<_> = papers.iter().filter(|p| p.doi.is_some()).collect();
308 assert!(!with_doi.is_empty(), "no papers with DOI");
309 for p in &with_doi {
310 assert!(p.doi.as_ref().unwrap().starts_with("10."));
311 }
312 }
313
314 #[test]
315 fn parse_year() {
316 let papers = parse_efetch_response(FIXTURE).unwrap();
317 for p in &papers {
318 assert!(p.year.is_some(), "paper {} missing year", p.id);
319 assert!(p.year.unwrap() >= 2000);
320 }
321 }
322
323 #[test]
324 fn parse_venue() {
325 let papers = parse_efetch_response(FIXTURE).unwrap();
326 let with_venue: Vec<_> = papers.iter().filter(|p| p.venue.is_some()).collect();
327 assert!(!with_venue.is_empty(), "no papers with venue");
328 }
329
330 #[test]
331 fn parse_abstract() {
332 let papers = parse_efetch_response(FIXTURE).unwrap();
333 let with_abstract: Vec<_> = papers.iter().filter(|p| p.abstract_text.is_some()).collect();
334 assert!(!with_abstract.is_empty(), "no papers with abstract");
335 for p in &with_abstract {
336 assert!(p.abstract_text.as_ref().unwrap().len() > 10);
337 }
338 }
339
340 const ESEARCH_FIXTURE: &str = include_str!("../../tests/fixtures/pmc_esearch.json");
341
342 #[test]
343 fn search_calls_esearch_then_efetch() {
344 let mut server = mockito::Server::new();
345 let esearch_mock = server
346 .mock("GET", mockito::Matcher::Regex("esearch".to_string()))
347 .with_status(200)
348 .with_body(ESEARCH_FIXTURE)
349 .create();
350 let efetch_mock = server
351 .mock("GET", mockito::Matcher::Regex("efetch".to_string()))
352 .with_status(200)
353 .with_body(FIXTURE)
354 .create();
355 let papers = search(&server.url(), "test", 3).unwrap();
356 assert!(!papers.is_empty());
357 esearch_mock.assert();
358 efetch_mock.assert();
359 }
360
361 #[test]
362 fn search_esearch_contains_db_pmc() {
363 let mut server = mockito::Server::new();
364 let mock = server
365 .mock("GET", mockito::Matcher::Regex("esearch.*db=pmc".to_string()))
366 .with_status(200)
367 .with_body(ESEARCH_FIXTURE)
368 .create();
369 server
370 .mock("GET", mockito::Matcher::Any)
371 .with_status(200)
372 .with_body(FIXTURE)
373 .create();
374 let _ = search(&server.url(), "test", 3);
375 mock.assert();
376 }
377
378 #[test]
379 fn search_efetch_contains_rettype_xml() {
380 let mut server = mockito::Server::new();
381 server
382 .mock("GET", mockito::Matcher::Regex("esearch".to_string()))
383 .with_status(200)
384 .with_body(ESEARCH_FIXTURE)
385 .create();
386 let mock = server
387 .mock("GET", mockito::Matcher::Regex("efetch.*rettype=xml".to_string()))
388 .with_status(200)
389 .with_body(FIXTURE)
390 .create();
391 let _ = search(&server.url(), "test", 3);
392 mock.assert();
393 }
394
395 #[test]
396 #[serial]
397 fn search_with_api_key() {
398 unsafe { std::env::set_var("NCBI_API_KEY", "pmc-test-key") };
399 let mut server = mockito::Server::new();
400 server
401 .mock("GET", mockito::Matcher::Regex("esearch.*api_key=pmc-test-key".to_string()))
402 .with_status(200)
403 .with_body(ESEARCH_FIXTURE)
404 .create();
405 server
406 .mock("GET", mockito::Matcher::Regex("efetch.*api_key=pmc-test-key".to_string()))
407 .with_status(200)
408 .with_body(FIXTURE)
409 .create();
410 let result = search(&server.url(), "test", 3);
411 unsafe { std::env::remove_var("NCBI_API_KEY") };
412 assert!(result.is_ok());
413 }
414
415 #[test]
416 fn search_empty_ids_skips_efetch() {
417 let empty_esearch = r#"{"esearchresult":{"idlist":[]}}"#;
418 let mut server = mockito::Server::new();
419 server
420 .mock("GET", mockito::Matcher::Regex("esearch".to_string()))
421 .with_status(200)
422 .with_body(empty_esearch)
423 .create();
424 let efetch_mock = server
426 .mock("GET", mockito::Matcher::Regex("efetch".to_string()))
427 .with_status(200)
428 .with_body(FIXTURE)
429 .expect(0)
430 .create();
431 let papers = search(&server.url(), "nonexistent", 3).unwrap();
432 assert!(papers.is_empty());
433 efetch_mock.assert();
434 }
435}