pubmed_client/pubmed/client/
citmatch.rs

1//! ECitMatch API operations for matching citations to PMIDs
2
3use crate::error::Result;
4use crate::pubmed::models::{CitationMatch, CitationMatchStatus, CitationMatches, CitationQuery};
5use tracing::{debug, info, instrument};
6
7use super::PubMedClient;
8
9impl PubMedClient {
10    /// Match citations to PMIDs using the ECitMatch API
11    ///
12    /// This method takes citation information (journal, year, volume, page, author)
13    /// and returns the corresponding PMIDs. Useful for identifying PMIDs from
14    /// reference lists.
15    ///
16    /// # Arguments
17    ///
18    /// * `citations` - List of citation queries to match
19    ///
20    /// # Returns
21    ///
22    /// Returns a `Result<CitationMatches>` containing match results for each citation
23    ///
24    /// # Example
25    ///
26    /// ```no_run
27    /// use pubmed_client::{PubMedClient, pubmed::CitationQuery};
28    ///
29    /// #[tokio::main]
30    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
31    ///     let client = PubMedClient::new();
32    ///     let citations = vec![
33    ///         CitationQuery::new(
34    ///             "proc natl acad sci u s a", "1991", "88", "3248", "mann bj", "Art1",
35    ///         ),
36    ///         CitationQuery::new(
37    ///             "science", "1987", "235", "182", "palmenberg ac", "Art2",
38    ///         ),
39    ///     ];
40    ///     let results = client.match_citations(&citations).await?;
41    ///     for m in &results.matches {
42    ///         println!("{}: {:?} ({:?})", m.key, m.pmid, m.status);
43    ///     }
44    ///     Ok(())
45    /// }
46    /// ```
47    #[instrument(skip(self), fields(citations_count = citations.len()))]
48    pub async fn match_citations(&self, citations: &[CitationQuery]) -> Result<CitationMatches> {
49        if citations.is_empty() {
50            return Ok(CitationMatches {
51                matches: Vec::new(),
52            });
53        }
54
55        // Build bdata parameter: citations separated by %0D (carriage return)
56        let bdata: String = citations
57            .iter()
58            .map(|c| c.to_bdata())
59            .collect::<Vec<_>>()
60            .join("%0D");
61
62        // `bdata` is pre-formatted with `+`, `|`, and `%0D` separators that must
63        // reach NCBI verbatim, so it is appended raw rather than percent-encoded.
64        let mut url = self.executor().build_url(
65            &self.base_url,
66            "ecitmatch.cgi",
67            &[("db", "pubmed"), ("retmode", "xml")],
68        )?;
69        url.push_str("&bdata=");
70        url.push_str(&bdata);
71
72        debug!(
73            citations_count = citations.len(),
74            "Making ECitMatch API request"
75        );
76        let response = self.executor().get(&url).await?;
77        let text = response.text().await?;
78
79        // Parse pipe-delimited response
80        let matches = Self::parse_ecitmatch_response(&text);
81
82        info!(
83            citations_count = citations.len(),
84            matched_count = matches
85                .iter()
86                .filter(|m| m.status == CitationMatchStatus::Found)
87                .count(),
88            "ECitMatch completed"
89        );
90
91        Ok(CitationMatches { matches })
92    }
93
94    /// Parse ECitMatch pipe-delimited response into CitationMatch results
95    pub(crate) fn parse_ecitmatch_response(text: &str) -> Vec<CitationMatch> {
96        let mut matches = Vec::new();
97
98        for line in text.lines() {
99            let line = line.trim();
100            if line.is_empty() {
101                continue;
102            }
103
104            let parts: Vec<&str> = line.split('|').collect();
105            if parts.len() >= 7 {
106                let pmid_str = parts[6].trim();
107                let (pmid, status) = if pmid_str.is_empty() {
108                    (None, CitationMatchStatus::NotFound)
109                } else if pmid_str.eq_ignore_ascii_case("AMBIGUOUS") {
110                    (None, CitationMatchStatus::Ambiguous)
111                } else {
112                    (Some(pmid_str.to_string()), CitationMatchStatus::Found)
113                };
114
115                matches.push(CitationMatch {
116                    journal: parts[0].replace('+', " "),
117                    year: parts[1].to_string(),
118                    volume: parts[2].to_string(),
119                    first_page: parts[3].to_string(),
120                    author_name: parts[4].replace('+', " "),
121                    key: parts[5].to_string(),
122                    pmid,
123                    status,
124                });
125            }
126        }
127
128        matches
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn test_parse_ecitmatch_response_found() {
138        let response = "proc natl acad sci u s a|1991|88|3248|mann bj|Art1|2014248\n";
139        let matches = PubMedClient::parse_ecitmatch_response(response);
140
141        assert_eq!(matches.len(), 1);
142        assert_eq!(matches[0].journal, "proc natl acad sci u s a");
143        assert_eq!(matches[0].year, "1991");
144        assert_eq!(matches[0].volume, "88");
145        assert_eq!(matches[0].first_page, "3248");
146        assert_eq!(matches[0].author_name, "mann bj");
147        assert_eq!(matches[0].key, "Art1");
148        assert_eq!(matches[0].pmid, Some("2014248".to_string()));
149        assert_eq!(matches[0].status, CitationMatchStatus::Found);
150    }
151
152    #[test]
153    fn test_parse_ecitmatch_response_not_found() {
154        let response = "fake journal|2000|1|1|nobody|ref1|\n";
155        let matches = PubMedClient::parse_ecitmatch_response(response);
156
157        assert_eq!(matches.len(), 1);
158        assert_eq!(matches[0].pmid, None);
159        assert_eq!(matches[0].status, CitationMatchStatus::NotFound);
160    }
161
162    #[test]
163    fn test_parse_ecitmatch_response_ambiguous() {
164        let response = "some journal|2000|1|1|smith|ref1|AMBIGUOUS\n";
165        let matches = PubMedClient::parse_ecitmatch_response(response);
166
167        assert_eq!(matches.len(), 1);
168        assert_eq!(matches[0].pmid, None);
169        assert_eq!(matches[0].status, CitationMatchStatus::Ambiguous);
170    }
171
172    #[test]
173    fn test_parse_ecitmatch_response_multiple() {
174        let response = concat!(
175            "proc natl acad sci u s a|1991|88|3248|mann bj|Art1|2014248\n",
176            "science|1987|235|182|palmenberg ac|Art2|3026048\n",
177        );
178        let matches = PubMedClient::parse_ecitmatch_response(response);
179
180        assert_eq!(matches.len(), 2);
181        assert_eq!(matches[0].pmid, Some("2014248".to_string()));
182        assert_eq!(matches[1].pmid, Some("3026048".to_string()));
183    }
184
185    #[test]
186    fn test_parse_ecitmatch_response_empty() {
187        let matches = PubMedClient::parse_ecitmatch_response("");
188        assert!(matches.is_empty());
189    }
190
191    #[test]
192    fn test_parse_ecitmatch_response_plus_to_space() {
193        let response = "proc+natl+acad+sci|1991|88|3248|mann+bj|Art1|2014248\n";
194        let matches = PubMedClient::parse_ecitmatch_response(response);
195
196        assert_eq!(matches[0].journal, "proc natl acad sci");
197        assert_eq!(matches[0].author_name, "mann bj");
198    }
199
200    #[test]
201    fn test_citation_query_to_bdata() {
202        let query = CitationQuery::new(
203            "proc natl acad sci u s a",
204            "1991",
205            "88",
206            "3248",
207            "mann bj",
208            "Art1",
209        );
210        let bdata = query.to_bdata();
211        assert_eq!(bdata, "proc+natl+acad+sci+u+s+a|1991|88|3248|mann+bj|Art1|");
212    }
213
214    #[test]
215    fn test_empty_citations_match() {
216        use tokio_test;
217        let client = PubMedClient::new();
218        let result = tokio_test::block_on(client.match_citations(&[]));
219        assert!(result.is_ok());
220        assert!(result.unwrap().matches.is_empty());
221    }
222}