pubmed_client/pubmed/client/
citmatch.rs1use crate::error::Result;
4use crate::pubmed::models::{CitationMatch, CitationMatchStatus, CitationMatches, CitationQuery};
5use tracing::{debug, info, instrument};
6
7use super::PubMedClient;
8
9impl PubMedClient {
10 #[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 let bdata: String = citations
57 .iter()
58 .map(|c| c.to_bdata())
59 .collect::<Vec<_>>()
60 .join("%0D");
61
62 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 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 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}