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}