pubmed_client/pubmed/client/history.rs
1//! History server operations (EPost, fetch from history, streaming search)
2
3use crate::common::PubMedId;
4use crate::error::{PubMedError, Result};
5use crate::pubmed::models::{EPostResult, HistorySession, PubMedArticle, SearchResult};
6use crate::pubmed::parser::parse_articles_from_xml;
7use crate::pubmed::query::SortOrder;
8use crate::pubmed::responses::{EPostResponse, ESearchResult};
9use tracing::{debug, info, instrument};
10
11use super::PubMedClient;
12
13/// State machine for streaming search results
14#[cfg(not(target_arch = "wasm32"))]
15enum SearchAllState {
16 /// Initial state before search
17 Initial { query: String, batch_size: usize },
18 /// Fetching articles from history server
19 Fetching {
20 session: HistorySession,
21 total: usize,
22 batch_size: usize,
23 current_offset: usize,
24 pending_articles: Vec<PubMedArticle>,
25 article_index: usize,
26 },
27 /// All articles have been fetched
28 Done,
29}
30
31impl PubMedClient {
32 /// Search for articles with history server support
33 ///
34 /// This method enables NCBI's history server feature, which stores search results
35 /// on the server and returns WebEnv/query_key identifiers. These can be used
36 /// with `fetch_from_history()` to efficiently paginate through large result sets.
37 ///
38 /// # Arguments
39 ///
40 /// * `query` - Search query string
41 /// * `limit` - Maximum number of PMIDs to return in the initial response
42 ///
43 /// # Returns
44 ///
45 /// Returns a `Result<SearchResult>` containing PMIDs and history session information
46 ///
47 /// # Example
48 ///
49 /// ```no_run
50 /// use pubmed_client::PubMedClient;
51 ///
52 /// #[tokio::main]
53 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
54 /// let client = PubMedClient::new();
55 /// let result = client.search_with_history("covid-19", 100).await?;
56 ///
57 /// println!("Total results: {}", result.total_count);
58 /// println!("First batch: {} PMIDs", result.pmids.len());
59 ///
60 /// // Use history session to fetch more results
61 /// if let Some(session) = result.history_session() {
62 /// let next_batch = client.fetch_from_history(&session, 100, 100).await?;
63 /// println!("Next batch: {} articles", next_batch.len());
64 /// }
65 ///
66 /// Ok(())
67 /// }
68 /// ```
69 #[instrument(skip(self), fields(query = %query, limit = limit))]
70 pub async fn search_with_history(&self, query: &str, limit: usize) -> Result<SearchResult> {
71 self.search_with_history_and_options(query, limit, None)
72 .await
73 }
74
75 /// Search for articles with history server support and sort options
76 ///
77 /// This method enables NCBI's history server feature, which stores search results
78 /// on the server and returns WebEnv/query_key identifiers. These can be used
79 /// with `fetch_from_history()` to efficiently paginate through large result sets.
80 ///
81 /// Also returns query translation showing how PubMed interpreted the query.
82 ///
83 /// # Arguments
84 ///
85 /// * `query` - Search query string
86 /// * `limit` - Maximum number of PMIDs to return in the initial response
87 /// * `sort` - Optional sort order for results
88 ///
89 /// # Returns
90 ///
91 /// Returns a `Result<SearchResult>` containing PMIDs, history session, and query translation
92 ///
93 /// # Example
94 ///
95 /// ```no_run
96 /// use pubmed_client::{PubMedClient, pubmed::SortOrder};
97 ///
98 /// #[tokio::main]
99 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
100 /// let client = PubMedClient::new();
101 /// let result = client
102 /// .search_with_history_and_options("asthma", 100, Some(&SortOrder::PublicationDate))
103 /// .await?;
104 ///
105 /// println!("Total results: {}", result.total_count);
106 /// if let Some(translation) = &result.query_translation {
107 /// println!("Query interpreted as: {}", translation);
108 /// }
109 /// Ok(())
110 /// }
111 /// ```
112 #[instrument(skip(self, sort), fields(query = %query, limit = limit))]
113 pub async fn search_with_history_and_options(
114 &self,
115 query: &str,
116 limit: usize,
117 sort: Option<&SortOrder>,
118 ) -> Result<SearchResult> {
119 if query.trim().is_empty() {
120 debug!("Empty query provided, returning empty results");
121 return Ok(SearchResult {
122 pmids: Vec::new(),
123 total_count: 0,
124 webenv: None,
125 query_key: None,
126 query_translation: None,
127 });
128 }
129
130 // Use usehistory=y to enable history server
131 let limit_str = limit.to_string();
132 let mut params = vec![
133 ("db", "pubmed"),
134 ("term", query),
135 ("retmax", limit_str.as_str()),
136 ("retstart", "0"),
137 ("retmode", "json"),
138 ("usehistory", "y"),
139 ];
140 if let Some(sort_order) = sort {
141 params.push(("sort", sort_order.as_api_param()));
142 }
143
144 debug!("Making ESearch API request with history");
145 let response = self.get_eutils("esearch.fcgi", ¶ms).await?;
146
147 let search_result: ESearchResult = response.json().await?;
148
149 // Check for API error response
150 if let Some(error_msg) = &search_result.esearchresult.error {
151 return Err(PubMedError::ApiError {
152 status: 200,
153 message: format!("NCBI ESearch API error: {}", error_msg),
154 });
155 }
156
157 let total_count: usize = search_result
158 .esearchresult
159 .count
160 .as_ref()
161 .and_then(|c| c.parse().ok())
162 .unwrap_or(0);
163
164 info!(
165 total_count = total_count,
166 returned_count = search_result.esearchresult.idlist.len(),
167 has_webenv = search_result.esearchresult.webenv.is_some(),
168 query_translation = ?search_result.esearchresult.querytranslation,
169 "Search with history completed"
170 );
171
172 Ok(SearchResult {
173 pmids: search_result.esearchresult.idlist,
174 total_count,
175 webenv: search_result.esearchresult.webenv,
176 query_key: search_result.esearchresult.query_key,
177 query_translation: search_result.esearchresult.querytranslation,
178 })
179 }
180
181 /// Upload a list of PMIDs to the NCBI History server using EPost
182 ///
183 /// This stores the UIDs on the server and returns WebEnv/query_key identifiers
184 /// that can be used with `fetch_from_history()` to retrieve article metadata.
185 ///
186 /// This is useful when you have a pre-existing list of PMIDs (e.g., from a file,
187 /// database, or external source) and want to use them with history server features
188 /// like batch fetching.
189 ///
190 /// # Arguments
191 ///
192 /// * `pmids` - Slice of PubMed IDs as strings
193 ///
194 /// # Returns
195 ///
196 /// Returns a `Result<EPostResult>` containing WebEnv and query_key
197 ///
198 /// # Errors
199 ///
200 /// * `ParseError::InvalidPmid` - If any PMID is invalid
201 /// * `PubMedError::RequestError` - If the HTTP request fails
202 /// * `PubMedError::ApiError` - If the NCBI API returns an error
203 ///
204 /// # Example
205 ///
206 /// ```no_run
207 /// use pubmed_client::PubMedClient;
208 ///
209 /// #[tokio::main]
210 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
211 /// let client = PubMedClient::new();
212 ///
213 /// // Upload PMIDs to the history server
214 /// let result = client.epost(&["31978945", "33515491", "25760099"]).await?;
215 ///
216 /// println!("WebEnv: {}", result.webenv);
217 /// println!("Query Key: {}", result.query_key);
218 ///
219 /// // Use the session to fetch articles
220 /// let session = result.history_session();
221 /// let articles = client.fetch_from_history(&session, 0, 100).await?;
222 /// println!("Fetched {} articles", articles.len());
223 ///
224 /// Ok(())
225 /// }
226 /// ```
227 #[instrument(skip(self), fields(pmids_count = pmids.len()))]
228 pub async fn epost(&self, pmids: &[&str]) -> Result<EPostResult> {
229 self.epost_internal(pmids, None).await
230 }
231
232 /// Upload PMIDs to an existing History server session using EPost
233 ///
234 /// This appends UIDs to an existing WebEnv session, allowing you to combine
235 /// multiple sets of IDs into a single session for subsequent operations.
236 ///
237 /// # Arguments
238 ///
239 /// * `pmids` - Slice of PubMed IDs as strings
240 /// * `session` - Existing history session to append to
241 ///
242 /// # Returns
243 ///
244 /// Returns a `Result<EPostResult>` with the updated session information.
245 /// The returned `webenv` will be the same as the input session, and a new
246 /// `query_key` will be assigned for the uploaded IDs.
247 ///
248 #[instrument(skip(self), fields(pmids_count = pmids.len()))]
249 pub async fn epost_to_session(
250 &self,
251 pmids: &[&str],
252 session: &HistorySession,
253 ) -> Result<EPostResult> {
254 self.epost_internal(pmids, Some(session)).await
255 }
256
257 /// Internal implementation for EPost
258 async fn epost_internal(
259 &self,
260 pmids: &[&str],
261 session: Option<&HistorySession>,
262 ) -> Result<EPostResult> {
263 if pmids.is_empty() {
264 return Err(PubMedError::InvalidQuery(
265 "PMID list cannot be empty for EPost".to_string(),
266 ));
267 }
268
269 // Validate all PMIDs upfront
270 let validated: Vec<u32> = pmids
271 .iter()
272 .map(|pmid| {
273 PubMedId::parse(pmid)
274 .map(|p| p.as_u32())
275 .map_err(PubMedError::from)
276 })
277 .collect::<Result<Vec<_>>>()?;
278
279 let id_list: String = validated
280 .iter()
281 .map(|id| id.to_string())
282 .collect::<Vec<_>>()
283 .join(",");
284
285 // Build form data for POST request
286 let mut params = vec![
287 ("db".to_string(), "pubmed".to_string()),
288 ("id".to_string(), id_list),
289 ("retmode".to_string(), "json".to_string()),
290 ];
291
292 if let Some(session) = session {
293 params.push(("WebEnv".to_string(), session.webenv.clone()));
294 }
295
296 // Append API parameters (api_key, email, tool)
297 params.extend(self.config().build_api_params());
298
299 // API parameters (api_key / email / tool) are already in the form body,
300 // so the URL only needs the bare endpoint.
301 let url = format!("{}/epost.fcgi", self.base_url);
302
303 debug!(pmids_count = pmids.len(), "Making EPost API request");
304
305 let response = self.executor().post_form(&url, ¶ms).await?;
306
307 let epost_response: EPostResponse = response.json().await?;
308
309 // Check for API error
310 if let Some(error_msg) = &epost_response.epostresult.error {
311 return Err(PubMedError::ApiError {
312 status: 200,
313 message: format!("NCBI EPost API error: {}", error_msg),
314 });
315 }
316
317 let webenv = epost_response
318 .epostresult
319 .webenv
320 .ok_or_else(|| PubMedError::WebEnvNotAvailable)?;
321
322 let query_key = epost_response
323 .epostresult
324 .query_key
325 .ok_or_else(|| PubMedError::WebEnvNotAvailable)?;
326
327 info!(
328 pmids_count = pmids.len(),
329 query_key = %query_key,
330 "EPost completed successfully"
331 );
332
333 Ok(EPostResult { webenv, query_key })
334 }
335
336 /// Fetch articles from history server using WebEnv session
337 ///
338 /// This method retrieves articles from a previously executed search using
339 /// the history server. It's useful for paginating through large result sets
340 /// without re-running the search query.
341 ///
342 /// # Arguments
343 ///
344 /// * `session` - History session containing WebEnv and query_key
345 /// * `start` - Starting index (0-based) for pagination
346 /// * `max` - Maximum number of articles to fetch
347 ///
348 /// # Returns
349 ///
350 /// Returns a `Result<Vec<PubMedArticle>>` containing the fetched articles
351 ///
352 /// # Note
353 ///
354 /// WebEnv sessions typically expire after 1 hour of inactivity.
355 ///
356 /// # Example
357 ///
358 /// ```no_run
359 /// use pubmed_client::PubMedClient;
360 ///
361 /// #[tokio::main]
362 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
363 /// let client = PubMedClient::new();
364 ///
365 /// // First, search with history
366 /// let result = client.search_with_history("cancer treatment", 100).await?;
367 ///
368 /// if let Some(session) = result.history_session() {
369 /// // Fetch articles 100-199
370 /// let batch2 = client.fetch_from_history(&session, 100, 100).await?;
371 /// println!("Fetched {} articles", batch2.len());
372 ///
373 /// // Fetch articles 200-299
374 /// let batch3 = client.fetch_from_history(&session, 200, 100).await?;
375 /// println!("Fetched {} more articles", batch3.len());
376 /// }
377 ///
378 /// Ok(())
379 /// }
380 /// ```
381 #[instrument(skip(self), fields(start = start, max = max))]
382 pub async fn fetch_from_history(
383 &self,
384 session: &HistorySession,
385 start: usize,
386 max: usize,
387 ) -> Result<Vec<PubMedArticle>> {
388 // Use WebEnv and query_key to fetch from history server
389 let start_str = start.to_string();
390 let max_str = max.to_string();
391
392 debug!("Making EFetch API request from history");
393 let response = self
394 .get_eutils(
395 "efetch.fcgi",
396 &[
397 ("db", "pubmed"),
398 ("query_key", session.query_key.as_str()),
399 ("WebEnv", session.webenv.as_str()),
400 ("retstart", start_str.as_str()),
401 ("retmax", max_str.as_str()),
402 ("retmode", "xml"),
403 ("rettype", "abstract"),
404 ],
405 )
406 .await?;
407
408 let xml_text = response.text().await?;
409
410 // Check for empty response or error
411 if xml_text.trim().is_empty() {
412 return Ok(Vec::new());
413 }
414
415 // Check for NCBI error response
416 if xml_text.contains("<ERROR>") {
417 let error_msg = xml_text
418 .split("<ERROR>")
419 .nth(1)
420 .and_then(|s| s.split("</ERROR>").next())
421 .unwrap_or("Unknown error");
422
423 return Err(PubMedError::HistorySessionError(error_msg.to_string()));
424 }
425
426 // Parse multiple articles from XML using serde-based parser
427 let articles = parse_articles_from_xml(&xml_text)?;
428
429 info!(
430 fetched_count = articles.len(),
431 start = start,
432 "Fetched articles from history"
433 );
434
435 Ok(articles)
436 }
437
438 /// Fetch all articles for a list of PMIDs using EPost and the History server
439 ///
440 /// This is the recommended method for fetching large numbers of articles by PMID.
441 /// It uploads the PMID list to the History server via EPost (using HTTP POST to
442 /// avoid URL length limits), then fetches articles in batches using pagination.
443 ///
444 /// For small lists (up to ~200 PMIDs), `fetch_articles()` works fine. Use this
445 /// method when you have hundreds or thousands of PMIDs.
446 ///
447 /// # Arguments
448 ///
449 /// * `pmids` - Slice of PubMed IDs as strings
450 ///
451 /// # Returns
452 ///
453 /// Returns a `Result<Vec<PubMedArticle>>` containing all fetched articles
454 ///
455 /// # Example
456 ///
457 /// ```no_run
458 /// use pubmed_client::PubMedClient;
459 ///
460 /// #[tokio::main]
461 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
462 /// let client = PubMedClient::new();
463 ///
464 /// // Works efficiently even with thousands of PMIDs
465 /// let pmids: Vec<&str> = vec!["31978945", "33515491", "25760099"];
466 /// let articles = client.fetch_all_by_pmids(&pmids).await?;
467 /// println!("Fetched {} articles", articles.len());
468 ///
469 /// Ok(())
470 /// }
471 /// ```
472 #[instrument(skip(self), fields(pmids_count = pmids.len()))]
473 pub async fn fetch_all_by_pmids(&self, pmids: &[&str]) -> Result<Vec<PubMedArticle>> {
474 if pmids.is_empty() {
475 return Ok(Vec::new());
476 }
477
478 // Upload PMIDs to History server
479 let epost_result = self.epost(pmids).await?;
480 let session = epost_result.history_session();
481
482 const BATCH_SIZE: usize = 200;
483 let total = pmids.len();
484 let mut all_articles = Vec::with_capacity(total);
485 let mut offset = 0;
486
487 while offset < total {
488 let articles = self
489 .fetch_from_history(&session, offset, BATCH_SIZE)
490 .await?;
491
492 if articles.is_empty() {
493 break;
494 }
495
496 info!(
497 offset = offset,
498 fetched = articles.len(),
499 total = total,
500 "Fetched batch from history"
501 );
502
503 offset += articles.len();
504 all_articles.extend(articles);
505 }
506
507 info!(
508 total_fetched = all_articles.len(),
509 requested = pmids.len(),
510 "fetch_all_by_pmids completed"
511 );
512
513 Ok(all_articles)
514 }
515
516 /// Search and stream all matching articles using history server
517 ///
518 /// This method performs a search and returns a stream that automatically
519 /// paginates through all results using the NCBI history server. It's ideal
520 /// for processing large result sets without loading all articles into memory.
521 ///
522 /// # Arguments
523 ///
524 /// * `query` - Search query string
525 /// * `batch_size` - Number of articles to fetch per batch (recommended: 100-500)
526 ///
527 /// # Returns
528 ///
529 /// Returns a `Stream` that yields `Result<PubMedArticle>` for each article
530 ///
531 /// # Example
532 ///
533 /// ```no_run
534 /// use pubmed_client::PubMedClient;
535 /// use futures_util::StreamExt;
536 /// use std::pin::pin;
537 ///
538 /// #[tokio::main]
539 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
540 /// let client = PubMedClient::new();
541 ///
542 /// let stream = client.search_all("cancer biomarker", 100);
543 /// let mut stream = pin!(stream);
544 /// let mut count = 0;
545 ///
546 /// while let Some(result) = stream.next().await {
547 /// match result {
548 /// Ok(article) => {
549 /// count += 1;
550 /// println!("{}: {}", article.pmid, article.title);
551 /// }
552 /// Err(e) => eprintln!("Error: {}", e),
553 /// }
554 ///
555 /// // Stop after 1000 articles
556 /// if count >= 1000 {
557 /// break;
558 /// }
559 /// }
560 ///
561 /// println!("Processed {} articles", count);
562 /// Ok(())
563 /// }
564 /// ```
565 #[cfg(not(target_arch = "wasm32"))]
566 pub fn search_all(
567 &self,
568 query: &str,
569 batch_size: usize,
570 ) -> impl futures_util::Stream<Item = Result<PubMedArticle>> + '_ {
571 use futures_util::stream;
572
573 let query = query.to_string();
574 let batch_size = batch_size.max(1); // Ensure at least 1
575
576 // The unfold body is a thin dispatcher; each state arm is handled by a
577 // dedicated, individually testable async method.
578 stream::unfold(
579 SearchAllState::Initial { query, batch_size },
580 move |state| async move {
581 match state {
582 SearchAllState::Initial { query, batch_size } => {
583 self.advance_initial(query, batch_size).await
584 }
585 state @ SearchAllState::Fetching { .. } => self.advance_fetching(state).await,
586 SearchAllState::Done => None,
587 }
588 },
589 )
590 }
591
592 /// Advance the stream from the `Initial` state.
593 ///
594 /// Runs the history-enabled search, then hands off to the fetching state to
595 /// retrieve and yield the first article. Returns `None` (ending the stream)
596 /// when the search matches nothing, and yields an error paired with `Done`
597 /// when the search fails or the server returns no usable history session.
598 #[cfg(not(target_arch = "wasm32"))]
599 async fn advance_initial(
600 &self,
601 query: String,
602 batch_size: usize,
603 ) -> Option<(Result<PubMedArticle>, SearchAllState)> {
604 let result = match self.search_with_history(&query, batch_size).await {
605 Ok(result) => result,
606 Err(e) => return Some((Err(e), SearchAllState::Done)),
607 };
608
609 // No matches: nothing to stream.
610 if result.pmids.is_empty() {
611 return None;
612 }
613
614 // History server is required to paginate; without it we cannot stream.
615 let Some(session) = result.history_session() else {
616 return Some((Err(PubMedError::WebEnvNotAvailable), SearchAllState::Done));
617 };
618
619 // Enter the fetching state with an empty buffer at offset 0; the
620 // fetching handler performs the first EFetch and yields its first
621 // article.
622 self.advance_fetching(SearchAllState::Fetching {
623 session,
624 total: result.total_count,
625 batch_size,
626 current_offset: 0,
627 pending_articles: Vec::new(),
628 article_index: 0,
629 })
630 .await
631 }
632
633 /// Advance the stream from the `Fetching` state.
634 ///
635 /// Yields the next buffered article, or fetches the next batch from the
636 /// history server when the buffer is exhausted. The stream ends (`None`)
637 /// once the buffer is empty and either the full result set has been covered
638 /// (`current_offset >= total`) or the server returns an empty batch. Fetch
639 /// errors are yielded paired with `Done`.
640 ///
641 /// # Panics
642 ///
643 /// Panics if called with a state other than `Fetching`. The dispatcher in
644 /// `search_all` guarantees this never happens.
645 #[cfg(not(target_arch = "wasm32"))]
646 async fn advance_fetching(
647 &self,
648 state: SearchAllState,
649 ) -> Option<(Result<PubMedArticle>, SearchAllState)> {
650 let SearchAllState::Fetching {
651 session,
652 total,
653 batch_size,
654 current_offset,
655 pending_articles,
656 article_index,
657 } = state
658 else {
659 unreachable!("advance_fetching must be called with a Fetching state");
660 };
661
662 // Yield the next article already buffered from the current batch.
663 if article_index < pending_articles.len() {
664 let article = pending_articles[article_index].clone();
665 return Some((
666 Ok(article),
667 SearchAllState::Fetching {
668 session,
669 total,
670 batch_size,
671 current_offset,
672 pending_articles,
673 article_index: article_index + 1,
674 },
675 ));
676 }
677
678 // Buffer exhausted and the whole result set has been covered.
679 if current_offset >= total {
680 return None;
681 }
682
683 // Fetch the next batch and yield its first article. `current_offset`
684 // advances by `batch_size` (the server-side record window), not by the
685 // number of articles parsed, so a page that drops an unparseable record
686 // never causes the next page to overlap and duplicate records.
687 match self
688 .fetch_from_history(&session, current_offset, batch_size)
689 .await
690 {
691 Ok(articles) => {
692 // A short/empty batch means the server has no more records.
693 let first = articles.first()?.clone();
694 Some((
695 Ok(first),
696 SearchAllState::Fetching {
697 session,
698 total,
699 batch_size,
700 current_offset: current_offset + batch_size,
701 pending_articles: articles,
702 article_index: 1,
703 },
704 ))
705 }
706 Err(e) => Some((Err(e), SearchAllState::Done)),
707 }
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use std::time::{Duration, Instant};
714
715 use super::*;
716
717 #[tokio::test]
718 async fn test_epost_empty_input() {
719 let client = PubMedClient::new();
720 let result = client.epost(&[]).await;
721 assert!(result.is_err());
722 if let Err(e) = result {
723 assert!(e.to_string().contains("empty"));
724 }
725 }
726
727 #[tokio::test]
728 async fn test_epost_invalid_pmid() {
729 let client = PubMedClient::new();
730 let result = client.epost(&["not_a_number"]).await;
731 assert!(result.is_err());
732 }
733
734 #[tokio::test]
735 async fn test_epost_validates_all_pmids_before_request() {
736 let client = PubMedClient::new();
737
738 let start = Instant::now();
739 let result = client.epost(&["31978945", "invalid", "33515491"]).await;
740 assert!(result.is_err());
741 let elapsed = start.elapsed();
742 assert!(elapsed < Duration::from_millis(100));
743 }
744
745 #[tokio::test]
746 async fn test_fetch_all_by_pmids_empty_input() {
747 let client = PubMedClient::new();
748 let result = client.fetch_all_by_pmids(&[]).await;
749 assert!(result.is_ok());
750 assert!(result.unwrap().is_empty());
751 }
752
753 #[tokio::test]
754 async fn test_fetch_all_by_pmids_invalid_pmid() {
755 let client = PubMedClient::new();
756 let result = client.fetch_all_by_pmids(&["not_a_number"]).await;
757 assert!(result.is_err());
758 }
759}