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", &params).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, &params).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}