pubmed_client/pmc/
client.rs

1use std::time::Duration;
2
3use crate::cache::{PmcCache, create_cache};
4use crate::common::PubMedId;
5use crate::config::ClientConfig;
6use crate::error::Result;
7use crate::pmc::extracted::ExtractedFigure;
8use crate::pmc::oa_api;
9use crate::pmc::oa_api::OaSubsetInfo;
10use crate::pmc::parser::parse_pmc_xml;
11use crate::rate_limit::RateLimiter;
12use crate::request::RequestExecutor;
13use crate::tls::install_default_crypto_provider;
14use pubmed_parser::pmc::PmcArticle;
15use reqwest::Client;
16use tracing::info;
17
18#[cfg(not(target_arch = "wasm32"))]
19use {crate::pmc::cloud::PmcCloudClient, std::path::Path};
20
21use super::common;
22
23/// Client for interacting with PMC (PubMed Central) API
24#[derive(Clone)]
25pub struct PmcClient {
26    client: Client,
27    base_url: String,
28    rate_limiter: RateLimiter,
29    config: ClientConfig,
30    #[cfg(not(target_arch = "wasm32"))]
31    cloud_client: PmcCloudClient,
32    cache: Option<PmcCache>,
33}
34
35impl PmcClient {
36    /// Create a new PMC client with default configuration
37    ///
38    /// Uses default NCBI rate limiting (3 requests/second) and no API key.
39    /// For production use, consider using `with_config()` to set an API key.
40    ///
41    /// # Example
42    ///
43    /// ```
44    /// use pubmed_client::PmcClient;
45    ///
46    /// let client = PmcClient::new();
47    /// ```
48    pub fn new() -> Self {
49        let config = ClientConfig::new();
50        Self::with_config(config)
51    }
52
53    pub fn get_pmc_config(&self) -> &ClientConfig {
54        &self.config
55    }
56
57    #[cfg(not(target_arch = "wasm32"))]
58    pub fn get_cloud_client_config(&self) -> &ClientConfig {
59        &self.cloud_client.config
60    }
61
62    /// Create a new PMC client with custom configuration
63    ///
64    /// # Arguments
65    ///
66    /// * `config` - Client configuration including rate limits, API key, etc.
67    ///
68    /// # Example
69    ///
70    /// ```
71    /// use pubmed_client::{PmcClient, ClientConfig};
72    ///
73    /// let config = ClientConfig::new()
74    ///     .with_api_key("your_api_key_here")
75    ///     .with_email("researcher@university.edu");
76    ///
77    /// let client = PmcClient::with_config(config);
78    /// ```
79    pub fn with_config(config: ClientConfig) -> Self {
80        let rate_limiter = config.create_rate_limiter();
81        let base_url = config.effective_base_url().to_string();
82
83        // rustls has no built-in provider under `rustls-tls`; install one first.
84        install_default_crypto_provider();
85
86        // reqwest's client builder only fails if the TLS backend cannot be
87        // initialized — an unrecoverable process-level environment error — so
88        // this infallible public constructor is allowed to `expect` here.
89        #[allow(clippy::expect_used)]
90        let client = {
91            #[cfg(not(target_arch = "wasm32"))]
92            {
93                Client::builder()
94                    .user_agent(config.effective_user_agent())
95                    .timeout(Duration::from_secs(config.timeout.as_secs()))
96                    .build()
97                    .expect("Failed to create HTTP client")
98            }
99
100            #[cfg(target_arch = "wasm32")]
101            {
102                Client::builder()
103                    .user_agent(config.effective_user_agent())
104                    .build()
105                    .expect("Failed to create HTTP client")
106            }
107        };
108
109        let cache = config.cache_config.as_ref().map(create_cache);
110
111        Self {
112            #[cfg(not(target_arch = "wasm32"))]
113            cloud_client: PmcCloudClient::with_shared(
114                client.clone(),
115                rate_limiter.clone(),
116                config.clone(),
117            ),
118            client,
119            base_url,
120            rate_limiter,
121            cache,
122            config,
123        }
124    }
125
126    /// Create a new PMC client with custom HTTP client and default configuration
127    ///
128    /// # Arguments
129    ///
130    /// * `client` - Custom reqwest client with specific configuration
131    ///
132    /// # Example
133    ///
134    /// ```
135    /// use pubmed_client::PmcClient;
136    /// use reqwest::Client;
137    /// use std::time::Duration;
138    ///
139    /// let http_client = Client::builder()
140    ///     .timeout(Duration::from_secs(30))
141    ///     .build()
142    ///     .unwrap();
143    ///
144    /// let client = PmcClient::with_client(http_client);
145    /// ```
146    pub fn with_client(client: Client) -> Self {
147        let config = ClientConfig::new();
148        let rate_limiter = config.create_rate_limiter();
149        let base_url = config.effective_base_url().to_string();
150
151        Self {
152            #[cfg(not(target_arch = "wasm32"))]
153            cloud_client: PmcCloudClient::with_shared(
154                client.clone(),
155                rate_limiter.clone(),
156                config.clone(),
157            ),
158            client,
159            base_url,
160            rate_limiter,
161            cache: None,
162            config,
163        }
164    }
165
166    /// Set a custom base URL for the PMC API
167    ///
168    /// # Arguments
169    ///
170    /// * `base_url` - The base URL for the PMC API
171    pub fn with_base_url(mut self, base_url: String) -> Self {
172        self.base_url = base_url;
173        self
174    }
175
176    /// Fetch full text from PMC using PMCID
177    ///
178    /// # Arguments
179    ///
180    /// * `pmcid` - PMC ID (with or without "PMC" prefix)
181    ///
182    /// # Returns
183    ///
184    /// Returns a `Result<PmcArticle>` containing the structured full text
185    ///
186    /// # Errors
187    ///
188    /// * `ParseError::PmcNotAvailable` - If PMC full text is not available
189    /// * `PubMedError::RequestError` - If the HTTP request fails
190    /// * `ParseError::XmlError` - If XML parsing fails
191    ///
192    /// # Example
193    ///
194    /// ```no_run
195    /// use pubmed_client::PmcClient;
196    ///
197    /// #[tokio::main]
198    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
199    ///     let client = PmcClient::new();
200    ///     let full_text = client.fetch_full_text("PMC7906746").await?;
201    ///     println!("Title: {}", full_text.title().unwrap_or("Untitled"));
202    ///     println!("Sections: {}", full_text.sections().len());
203    ///     Ok(())
204    /// }
205    /// ```
206    pub async fn fetch_full_text(&self, pmcid: &str) -> Result<PmcArticle> {
207        let normalized_pmcid = common::normalize_pmcid(pmcid);
208        let cache_key = format!("pmc:{}", normalized_pmcid);
209
210        // Check cache first if available
211        if let Some(cache) = &self.cache
212            && let Some(cached) = cache.get(&cache_key).await
213        {
214            info!(pmcid = %normalized_pmcid, "Cache hit for PMC full text");
215            return Ok(cached);
216        }
217
218        // Fetch from API if not cached
219        let xml_content = self.fetch_xml(pmcid).await?;
220        let full_text = parse_pmc_xml(&xml_content, &normalized_pmcid)?;
221
222        // Store in cache if available
223        if let Some(cache) = &self.cache {
224            cache.insert(cache_key, full_text.clone()).await;
225        }
226
227        Ok(full_text)
228    }
229
230    /// Fetch raw XML content from PMC
231    ///
232    /// # Arguments
233    ///
234    /// * `pmcid` - PMC ID (with or without "PMC" prefix)
235    ///
236    /// # Returns
237    ///
238    /// Returns a `Result<String>` containing the raw XML content
239    pub async fn fetch_xml(&self, pmcid: &str) -> Result<String> {
240        common::fetch_pmc_xml(&self.executor(), &self.base_url, pmcid).await
241    }
242
243    /// Check if PMC full text is available for a given PMID
244    ///
245    /// # Arguments
246    ///
247    /// * `pmid` - PubMed ID
248    ///
249    /// # Returns
250    ///
251    /// Returns `Result<Option<String>>` containing the PMCID if available
252    ///
253    /// # Example
254    ///
255    /// ```no_run
256    /// use pubmed_client::PmcClient;
257    ///
258    /// #[tokio::main]
259    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
260    ///     let client = PmcClient::new();
261    ///     if let Some(pmcid) = client.check_pmc_availability("33515491").await? {
262    ///         println!("PMC available: {}", pmcid);
263    ///         let full_text = client.fetch_full_text(&pmcid).await?;
264    ///         println!("Title: {}", full_text.title().unwrap_or("Untitled"));
265    ///     } else {
266    ///         println!("PMC not available");
267    ///     }
268    ///     Ok(())
269    /// }
270    /// ```
271    pub async fn check_pmc_availability(&self, pmid: &str) -> Result<Option<String>> {
272        // Validate and parse PMID
273        let pmid_obj = PubMedId::parse(pmid)?;
274        let pmid_value = pmid_obj.as_u32().to_string();
275
276        let response = self
277            .executor()
278            .get_endpoint(
279                &self.base_url,
280                "elink.fcgi",
281                &[
282                    ("dbfrom", "pubmed"),
283                    ("db", "pmc"),
284                    ("id", pmid_value.as_str()),
285                    ("retmode", "json"),
286                ],
287            )
288            .await?;
289
290        let link_result: serde_json::Value = response.json().await?;
291
292        // Extract PMCID from response
293        if let Some(linksets) = link_result["linksets"].as_array() {
294            for linkset in linksets {
295                if let Some(linksetdbs) = linkset["linksetdbs"].as_array() {
296                    for linksetdb in linksetdbs {
297                        if linksetdb["dbto"] == "pmc"
298                            && let Some(links) = linksetdb["links"].as_array()
299                            && let Some(pmcid) = links.first().and_then(json_uid)
300                        {
301                            return Ok(Some(format!("PMC{pmcid}")));
302                        }
303                    }
304                }
305            }
306        }
307        Ok(None)
308    }
309
310    /// Check if a PMC article is in the OA (Open Access) subset
311    ///
312    /// The OA subset contains articles with programmatic access to full-text XML.
313    /// Some publishers restrict programmatic access even though the article may be
314    /// viewable on the PMC website.
315    ///
316    /// # Arguments
317    ///
318    /// * `pmcid` - PMC ID (with or without "PMC" prefix)
319    ///
320    /// # Returns
321    ///
322    /// Returns `Result<OaSubsetInfo>` containing detailed information about OA availability
323    ///
324    /// # Example
325    ///
326    /// ```no_run
327    /// use pubmed_client::PmcClient;
328    ///
329    /// #[tokio::main]
330    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
331    ///     let client = PmcClient::new();
332    ///     let oa_info = client.is_oa_subset("PMC7906746").await?;
333    ///
334    ///     if oa_info.is_oa_subset {
335    ///         println!("Article is in OA subset");
336    ///         if let Some(link) = oa_info.download_link {
337    ///             println!("Download: {}", link);
338    ///         }
339    ///     } else {
340    ///         println!("Article is NOT in OA subset");
341    ///         if let Some(code) = oa_info.error_code {
342    ///             println!("Reason: {}", code);
343    ///         }
344    ///     }
345    ///     Ok(())
346    /// }
347    /// ```
348    pub async fn is_oa_subset(&self, pmcid: &str) -> Result<OaSubsetInfo> {
349        let url = oa_api::build_oa_api_url(pmcid)?;
350
351        let response = self.executor().get(&url).await?;
352
353        let xml_content = response.text().await?;
354
355        // Parse the OA API XML response
356        Ok(oa_api::parse_oa_response(&xml_content, pmcid)?)
357    }
358
359    /// Download a PMC article's files from the PMC OA Cloud (AWS S3) service
360    ///
361    /// # Arguments
362    ///
363    /// * `pmcid` - PMC ID (with or without "PMC" prefix)
364    /// * `output_dir` - Directory to download the article's files into
365    ///
366    /// # Returns
367    ///
368    /// Returns a `Result<Vec<String>>` containing the list of downloaded file paths
369    ///
370    /// # Errors
371    ///
372    /// * `ParseError::InvalidPmid` - If the PMCID format is invalid
373    /// * `PubMedError::RequestError` - If the HTTP request fails
374    /// * `ParseError::IoError` - If file operations fail
375    /// * `ParseError::PmcNotAvailable` - If the article is not available in OA
376    ///
377    /// # Example
378    ///
379    /// ```no_run
380    /// use pubmed_client::PmcClient;
381    /// use std::path::Path;
382    ///
383    /// #[tokio::main]
384    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
385    ///     let client = PmcClient::new();
386    ///     let output_dir = Path::new("./extracted_articles");
387    ///     let files = client.download_files("PMC7906746", output_dir).await?;
388    ///
389    ///     for file in files {
390    ///         println!("Downloaded: {}", file);
391    ///     }
392    ///     Ok(())
393    /// }
394    /// ```
395    #[cfg(not(target_arch = "wasm32"))]
396    pub async fn download_files<P: AsRef<Path>>(
397        &self,
398        pmcid: &str,
399        output_dir: P,
400    ) -> Result<Vec<String>> {
401        self.cloud_client.download_files(pmcid, output_dir).await
402    }
403
404    /// Download the article's files and match figures with their captions from XML
405    ///
406    /// # Arguments
407    ///
408    /// * `pmcid` - PMC ID (with or without "PMC" prefix)
409    /// * `output_dir` - Directory to download the article's files into
410    ///
411    /// # Returns
412    ///
413    /// Returns a `Result<Vec<ExtractedFigure>>` containing figures with both XML metadata and file paths
414    ///
415    /// # Errors
416    ///
417    /// * `ParseError::InvalidPmid` - If the PMCID format is invalid
418    /// * `PubMedError::RequestError` - If the HTTP request fails
419    /// * `ParseError::IoError` - If file operations fail
420    /// * `ParseError::PmcNotAvailable` - If the article is not available in OA
421    ///
422    /// # Example
423    ///
424    /// ```no_run
425    /// use pubmed_client::PmcClient;
426    /// use std::path::Path;
427    ///
428    /// #[tokio::main]
429    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
430    ///     let client = PmcClient::new();
431    ///     let output_dir = Path::new("./extracted_articles");
432    ///     let figures = client.extract_figures_with_captions("PMC7906746", output_dir).await?;
433    ///
434    ///     for figure in figures {
435    ///         println!("Figure {}: {:?}", figure.figure.id, figure.figure.caption);
436    ///         println!("File: {}", figure.extracted_file_path);
437    ///     }
438    ///     Ok(())
439    /// }
440    /// ```
441    #[cfg(not(target_arch = "wasm32"))]
442    pub async fn extract_figures_with_captions<P: AsRef<Path>>(
443        &self,
444        pmcid: &str,
445        output_dir: P,
446    ) -> Result<Vec<ExtractedFigure>> {
447        self.cloud_client
448            .extract_figures_with_captions(pmcid, output_dir)
449            .await
450    }
451
452    /// Clear all cached PMC data
453    ///
454    /// # Example
455    ///
456    /// ```no_run
457    /// use pubmed_client::PmcClient;
458    ///
459    /// #[tokio::main]
460    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
461    ///     let client = PmcClient::new();
462    ///     client.clear_cache().await;
463    ///     Ok(())
464    /// }
465    /// ```
466    pub async fn clear_cache(&self) {
467        if let Some(cache) = &self.cache {
468            cache.clear().await;
469            info!("Cleared PMC cache");
470        }
471    }
472
473    /// Get cache statistics
474    ///
475    /// Returns the number of items in cache, or 0 if caching is disabled
476    ///
477    /// # Example
478    ///
479    /// ```
480    /// use pubmed_client::PmcClient;
481    ///
482    /// let client = PmcClient::new();
483    /// let count = client.cache_entry_count();
484    /// println!("Cache entries: {}", count);
485    /// ```
486    pub fn cache_entry_count(&self) -> u64 {
487        self.cache.as_ref().map_or(0, |cache| cache.entry_count())
488    }
489
490    /// Synchronize cache operations to ensure all pending operations are flushed
491    ///
492    /// This is useful for testing to ensure cache statistics are accurate
493    pub async fn sync_cache(&self) {
494        if let Some(cache) = &self.cache {
495            cache.sync().await;
496        }
497    }
498
499    /// Build a request executor borrowing this client's HTTP client, rate limiter, and config.
500    fn executor(&self) -> RequestExecutor<'_> {
501        RequestExecutor::new(&self.client, &self.rate_limiter, &self.config)
502    }
503}
504
505impl Default for PmcClient {
506    fn default() -> Self {
507        Self::new()
508    }
509}
510
511/// Read an Entrez UID out of an ELink `links` entry.
512///
513/// NCBI writes these as JSON strings, but has used bare numbers too, so both
514/// are accepted. Formatting the `Value` directly would embed its JSON quotes in
515/// the id.
516fn json_uid(value: &serde_json::Value) -> Option<String> {
517    match value {
518        serde_json::Value::String(uid) => Some(uid.clone()),
519        serde_json::Value::Number(uid) => Some(uid.to_string()),
520        _ => None,
521    }
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_json_uid_accepts_strings_and_numbers() {
530        assert_eq!(
531            json_uid(&serde_json::json!("7092803")).as_deref(),
532            Some("7092803")
533        );
534        assert_eq!(
535            json_uid(&serde_json::json!(7092803)).as_deref(),
536            Some("7092803")
537        );
538        assert_eq!(json_uid(&serde_json::json!(null)), None);
539    }
540
541    #[test]
542    fn test_client_creation() {
543        let client = PmcClient::new();
544        assert!(client.base_url.contains("eutils.ncbi.nlm.nih.gov"));
545    }
546
547    #[test]
548    fn test_custom_base_url() {
549        let client = PmcClient::new().with_base_url("https://custom.api.example.com".to_string());
550        assert_eq!(client.base_url, "https://custom.api.example.com");
551    }
552}