pubmed_client/pubmed/client/
elink.rs

1//! ELink API operations for cross-referencing between NCBI databases
2
3use crate::error::Result;
4use crate::pubmed::models::{Citations, PmcLinks, RelatedArticles};
5use crate::pubmed::responses::ELinkResponse;
6use tracing::{debug, info, instrument};
7
8use super::PubMedClient;
9
10impl PubMedClient {
11    /// Get related articles for given PMIDs
12    ///
13    /// # Arguments
14    ///
15    /// * `pmids` - List of PubMed IDs to find related articles for
16    ///
17    /// # Returns
18    ///
19    /// Returns a `Result<RelatedArticles>` containing related article information
20    ///
21    /// # Example
22    ///
23    /// ```no_run
24    /// use pubmed_client::PubMedClient;
25    ///
26    /// #[tokio::main]
27    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
28    ///     let client = PubMedClient::new();
29    ///     let related = client.get_related_articles(&[31978945]).await?;
30    ///     println!("Found {} related articles", related.related_pmids.len());
31    ///     Ok(())
32    /// }
33    /// ```
34    #[instrument(skip(self), fields(pmids_count = pmids.len()))]
35    pub async fn get_related_articles(&self, pmids: &[u32]) -> Result<RelatedArticles> {
36        if pmids.is_empty() {
37            return Ok(RelatedArticles {
38                source_pmids: Vec::new(),
39                related_pmids: Vec::new(),
40                link_type: "pubmed_pubmed".to_string(),
41            });
42        }
43
44        let elink_response = self.elink_request(pmids, "pubmed", "pubmed_pubmed").await?;
45
46        let mut all_related_pmids = Self::collect_linked_pmids(elink_response, "pubmed_pubmed");
47        // Drop the original PMIDs from their own related set
48        all_related_pmids.retain(|&pmid| !pmids.contains(&pmid));
49
50        info!(
51            source_count = pmids.len(),
52            related_count = all_related_pmids.len(),
53            "Related articles retrieved successfully"
54        );
55
56        Ok(RelatedArticles {
57            source_pmids: pmids.to_vec(),
58            related_pmids: all_related_pmids,
59            link_type: "pubmed_pubmed".to_string(),
60        })
61    }
62
63    /// Get PMC links for given PMIDs (full-text availability)
64    ///
65    /// # Arguments
66    ///
67    /// * `pmids` - List of PubMed IDs to check for PMC availability
68    ///
69    /// # Returns
70    ///
71    /// Returns a `Result<PmcLinks>` containing PMC IDs with full text available
72    ///
73    /// # Example
74    ///
75    /// ```no_run
76    /// use pubmed_client::PubMedClient;
77    ///
78    /// #[tokio::main]
79    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
80    ///     let client = PubMedClient::new();
81    ///     let pmc_links = client.get_pmc_links(&[31978945]).await?;
82    ///     println!("Found {} PMC articles", pmc_links.pmc_ids.len());
83    ///     Ok(())
84    /// }
85    /// ```
86    #[instrument(skip(self), fields(pmids_count = pmids.len()))]
87    pub async fn get_pmc_links(&self, pmids: &[u32]) -> Result<PmcLinks> {
88        if pmids.is_empty() {
89            return Ok(PmcLinks {
90                source_pmids: Vec::new(),
91                pmc_ids: Vec::new(),
92            });
93        }
94
95        let elink_response = self.elink_request(pmids, "pmc", "pubmed_pmc").await?;
96
97        let mut pmc_ids = Vec::new();
98
99        for linkset in elink_response.linksets {
100            if let Some(linkset_dbs) = linkset.linkset_dbs {
101                for linkset_db in linkset_dbs {
102                    if linkset_db.link_name == "pubmed_pmc" && linkset_db.db_to == "pmc" {
103                        pmc_ids.extend(linkset_db.links);
104                    }
105                }
106            }
107        }
108
109        // Remove duplicates
110        pmc_ids.sort();
111        pmc_ids.dedup();
112
113        info!(
114            source_count = pmids.len(),
115            pmc_count = pmc_ids.len(),
116            "PMC links retrieved successfully"
117        );
118
119        Ok(PmcLinks {
120            source_pmids: pmids.to_vec(),
121            pmc_ids,
122        })
123    }
124
125    /// Get citing articles for given PMIDs
126    ///
127    /// This method retrieves articles that cite the specified PMIDs from the PubMed database.
128    /// The citation count returned represents only citations within the PubMed database
129    /// (peer-reviewed journal articles indexed in PubMed).
130    ///
131    /// # Important Note on Citation Counts
132    ///
133    /// The citation count from this method may be **lower** than counts from other sources like
134    /// Google Scholar, Web of Science, or scite.ai because:
135    ///
136    /// - **PubMed citations** (this method): Only includes peer-reviewed articles in PubMed
137    /// - **Google Scholar/scite.ai**: Includes preprints, books, conference proceedings, and other sources
138    ///
139    /// For example, PMID 31978945 shows:
140    /// - PubMed (this API): ~14,000 citations (PubMed database only)
141    /// - scite.ai: ~23,000 citations (broader sources)
142    ///
143    /// This is expected behavior - this method provides accurate PubMed-specific citation data.
144    ///
145    /// # Arguments
146    ///
147    /// * `pmids` - List of PubMed IDs to find citing articles for
148    ///
149    /// # Returns
150    ///
151    /// Returns a `Result<Citations>` containing citing article information
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use pubmed_client::PubMedClient;
157    ///
158    /// #[tokio::main]
159    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
160    ///     let client = PubMedClient::new();
161    ///     let citations = client.get_citations(&[31978945]).await?;
162    ///     println!("Found {} citing articles in PubMed", citations.citing_pmids.len());
163    ///     Ok(())
164    /// }
165    /// ```
166    #[instrument(skip(self), fields(pmids_count = pmids.len()))]
167    pub async fn get_citations(&self, pmids: &[u32]) -> Result<Citations> {
168        if pmids.is_empty() {
169            return Ok(Citations {
170                source_pmids: Vec::new(),
171                citing_pmids: Vec::new(),
172                link_type: "pubmed_pubmed_citedin".to_string(),
173            });
174        }
175
176        let elink_response = self
177            .elink_request(pmids, "pubmed", "pubmed_pubmed_citedin")
178            .await?;
179
180        let citing_pmids = Self::collect_linked_pmids(elink_response, "pubmed_pubmed_citedin");
181
182        info!(
183            source_count = pmids.len(),
184            citing_count = citing_pmids.len(),
185            "Citations retrieved successfully"
186        );
187
188        Ok(Citations {
189            source_pmids: pmids.to_vec(),
190            citing_pmids,
191            link_type: "pubmed_pubmed_citedin".to_string(),
192        })
193    }
194
195    /// Collect deduplicated u32 PMIDs from an ELink response for a given link name.
196    ///
197    /// Walks `linksets → linkset_dbs → links`, keeping only entries whose
198    /// `link_name` matches, parsing each link ID to `u32`, then sorting and
199    /// deduplicating the result. Shared by `get_related_articles` and
200    /// `get_citations`, which differ only in the link name and result wrapper.
201    fn collect_linked_pmids(response: ELinkResponse, link_name: &str) -> Vec<u32> {
202        let mut pmids = Vec::new();
203
204        for linkset in response.linksets {
205            if let Some(linkset_dbs) = linkset.linkset_dbs {
206                for linkset_db in linkset_dbs {
207                    if linkset_db.link_name == link_name {
208                        for link_id in linkset_db.links {
209                            if let Ok(pmid) = link_id.parse::<u32>() {
210                                pmids.push(pmid);
211                            }
212                        }
213                    }
214                }
215            }
216        }
217
218        pmids.sort_unstable();
219        pmids.dedup();
220        pmids
221    }
222
223    /// Internal helper method for ELink API requests
224    pub(crate) async fn elink_request(
225        &self,
226        pmids: &[u32],
227        target_db: &str,
228        link_name: &str,
229    ) -> Result<ELinkResponse> {
230        // Convert PMIDs to strings and join with commas
231        let id_list: Vec<String> = pmids.iter().map(|id| id.to_string()).collect();
232        let ids = id_list.join(",");
233
234        debug!("Making ELink API request");
235        let response = self
236            .get_eutils(
237                "elink.fcgi",
238                &[
239                    ("dbfrom", "pubmed"),
240                    ("db", target_db),
241                    ("id", ids.as_str()),
242                    ("linkname", link_name),
243                    ("retmode", "json"),
244                ],
245            )
246            .await?;
247
248        let elink_response: ELinkResponse = response.json().await?;
249        Ok(elink_response)
250    }
251}