pubmed_client/pubmed/client/
espell.rs1use crate::error::{PubMedError, Result};
4use crate::pubmed::models::{SpellCheckResult, SpelledQuerySegment};
5use tracing::{debug, info, instrument};
6
7use super::PubMedClient;
8
9impl PubMedClient {
10 #[instrument(skip(self), fields(term = %term))]
46 pub async fn spell_check(&self, term: &str) -> Result<SpellCheckResult> {
47 self.spell_check_db(term, "pubmed").await
48 }
49
50 #[instrument(skip(self), fields(term = %term, db = %db))]
77 pub async fn spell_check_db(&self, term: &str, db: &str) -> Result<SpellCheckResult> {
78 let term = term.trim();
79 if term.is_empty() {
80 return Err(PubMedError::InvalidQuery(
81 "Search term cannot be empty".to_string(),
82 ));
83 }
84
85 let db = db.trim();
86 if db.is_empty() {
87 return Err(PubMedError::ApiError {
88 status: 400,
89 message: "Database name cannot be empty".to_string(),
90 });
91 }
92
93 debug!(term = %term, db = %db, "Making ESpell API request");
94 let response = self
95 .get_eutils("espell.fcgi", &[("db", db), ("term", term)])
96 .await?;
97 let xml_text = response.text().await?;
98
99 let result = Self::parse_espell_response(&xml_text, term, db)?;
100
101 info!(
102 term = %term,
103 corrected = %result.corrected_query,
104 has_corrections = result.has_corrections(),
105 "ESpell completed"
106 );
107
108 Ok(result)
109 }
110
111 pub(crate) fn parse_espell_response(
113 xml: &str,
114 query_term: &str,
115 db: &str,
116 ) -> Result<SpellCheckResult> {
117 use crate::pubmed::client::xml_text::extract_text_between;
118
119 let error = extract_text_between(xml, "<ERROR>", "</ERROR>");
121 if let Some(error_msg) = error
122 && !error_msg.is_empty()
123 {
124 return Err(PubMedError::ApiError {
125 status: 200,
126 message: format!("NCBI ESpell API error: {}", error_msg),
127 });
128 }
129
130 let database = extract_text_between(xml, "<Database>", "</Database>")
131 .unwrap_or_else(|| db.to_string());
132
133 let query = extract_text_between(xml, "<Query>", "</Query>")
134 .unwrap_or_else(|| query_term.to_string());
135
136 let corrected_query =
137 extract_text_between(xml, "<CorrectedQuery>", "</CorrectedQuery>").unwrap_or_default();
138
139 let spelled_query = if let Some(spelled_content) =
141 extract_text_between(xml, "<SpelledQuery>", "</SpelledQuery>")
142 {
143 Self::parse_spelled_query_segments(&spelled_content)
144 } else {
145 Vec::new()
146 };
147
148 Ok(SpellCheckResult {
149 database,
150 query,
151 corrected_query,
152 spelled_query,
153 })
154 }
155
156 fn parse_spelled_query_segments(content: &str) -> Vec<SpelledQuerySegment> {
158 let mut segments = Vec::new();
159 let mut pos = 0;
160
161 while pos < content.len() {
162 let orig_pos = content[pos..].find("<Original>");
163 let repl_pos = content[pos..].find("<Replaced>");
164
165 match (orig_pos, repl_pos) {
166 (Some(o), Some(r)) if o <= r => {
167 let abs_start = pos + o;
169 if let Some(end_offset) = content[abs_start..].find("</Original>") {
170 let text_start = abs_start + "<Original>".len();
171 let text_end = abs_start + end_offset;
172 segments.push(SpelledQuerySegment::Original(
173 content[text_start..text_end].to_string(),
174 ));
175 pos = text_end + "</Original>".len();
176 } else {
177 break;
178 }
179 }
180 (Some(_), Some(r)) => {
181 let abs_start = pos + r;
183 if let Some(end_offset) = content[abs_start..].find("</Replaced>") {
184 let text_start = abs_start + "<Replaced>".len();
185 let text_end = abs_start + end_offset;
186 segments.push(SpelledQuerySegment::Replaced(
187 content[text_start..text_end].to_string(),
188 ));
189 pos = text_end + "</Replaced>".len();
190 } else {
191 break;
192 }
193 }
194 (Some(o), None) => {
195 let abs_start = pos + o;
197 if let Some(end_offset) = content[abs_start..].find("</Original>") {
198 let text_start = abs_start + "<Original>".len();
199 let text_end = abs_start + end_offset;
200 segments.push(SpelledQuerySegment::Original(
201 content[text_start..text_end].to_string(),
202 ));
203 pos = text_end + "</Original>".len();
204 } else {
205 break;
206 }
207 }
208 (None, Some(r)) => {
209 let abs_start = pos + r;
211 if let Some(end_offset) = content[abs_start..].find("</Replaced>") {
212 let text_start = abs_start + "<Replaced>".len();
213 let text_end = abs_start + end_offset;
214 segments.push(SpelledQuerySegment::Replaced(
215 content[text_start..text_end].to_string(),
216 ));
217 pos = text_end + "</Replaced>".len();
218 } else {
219 break;
220 }
221 }
222 (None, None) => break,
223 }
224 }
225
226 segments
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn test_parse_espell_response_with_corrections() {
236 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
237<eSpellResult>
238 <Database>pubmed</Database>
239 <Query>asthmaa OR alergies</Query>
240 <CorrectedQuery>asthma or allergies</CorrectedQuery>
241 <SpelledQuery>
242 <Original></Original>
243 <Replaced>asthma</Replaced>
244 <Original> OR </Original>
245 <Replaced>allergies</Replaced>
246 </SpelledQuery>
247 <ERROR/>
248</eSpellResult>"#;
249
250 let result =
251 PubMedClient::parse_espell_response(xml, "asthmaa OR alergies", "pubmed").unwrap();
252 assert_eq!(result.database, "pubmed");
253 assert_eq!(result.query, "asthmaa OR alergies");
254 assert_eq!(result.corrected_query, "asthma or allergies");
255 assert!(result.has_corrections());
256
257 let replacements = result.replacements();
258 assert_eq!(replacements.len(), 2);
259 assert_eq!(replacements[0], "asthma");
260 assert_eq!(replacements[1], "allergies");
261
262 assert_eq!(result.spelled_query.len(), 4);
263 assert_eq!(
264 result.spelled_query[0],
265 SpelledQuerySegment::Original("".to_string())
266 );
267 assert_eq!(
268 result.spelled_query[1],
269 SpelledQuerySegment::Replaced("asthma".to_string())
270 );
271 assert_eq!(
272 result.spelled_query[2],
273 SpelledQuerySegment::Original(" OR ".to_string())
274 );
275 assert_eq!(
276 result.spelled_query[3],
277 SpelledQuerySegment::Replaced("allergies".to_string())
278 );
279 }
280
281 #[test]
282 fn test_parse_espell_response_no_corrections() {
283 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
284<eSpellResult>
285 <Database>pubmed</Database>
286 <Query>asthma</Query>
287 <CorrectedQuery>asthma</CorrectedQuery>
288 <SpelledQuery>
289 <Original>asthma</Original>
290 </SpelledQuery>
291 <ERROR/>
292</eSpellResult>"#;
293
294 let result = PubMedClient::parse_espell_response(xml, "asthma", "pubmed").unwrap();
295 assert_eq!(result.query, "asthma");
296 assert_eq!(result.corrected_query, "asthma");
297 assert!(!result.has_corrections());
298 assert!(result.replacements().is_empty());
299 }
300
301 #[test]
302 fn test_parse_espell_response_empty_corrected() {
303 let xml = r#"<eSpellResult>
304 <Database>pubmed</Database>
305 <Query>xyznonexistent</Query>
306 <CorrectedQuery></CorrectedQuery>
307 <SpelledQuery/>
308 <ERROR/>
309</eSpellResult>"#;
310
311 let result = PubMedClient::parse_espell_response(xml, "xyznonexistent", "pubmed").unwrap();
312 assert_eq!(result.query, "xyznonexistent");
313 assert_eq!(result.corrected_query, "");
314 }
315
316 #[test]
317 fn test_parse_espell_response_pmc_database() {
318 let xml = r#"<eSpellResult>
319 <Database>pmc</Database>
320 <Query>fiberblast</Query>
321 <CorrectedQuery>fibroblast</CorrectedQuery>
322 <SpelledQuery>
323 <Replaced>fibroblast</Replaced>
324 </SpelledQuery>
325 <ERROR/>
326</eSpellResult>"#;
327
328 let result = PubMedClient::parse_espell_response(xml, "fiberblast", "pmc").unwrap();
329 assert_eq!(result.database, "pmc");
330 assert_eq!(result.corrected_query, "fibroblast");
331 assert!(result.has_corrections());
332 }
333
334 #[test]
335 fn test_spell_check_empty_term() {
336 use tokio_test;
337 let client = PubMedClient::new();
338 let result = tokio_test::block_on(client.spell_check(""));
339 assert!(result.is_err());
340 }
341
342 #[test]
343 fn test_spell_check_whitespace_term() {
344 use tokio_test;
345 let client = PubMedClient::new();
346 let result = tokio_test::block_on(client.spell_check(" "));
347 assert!(result.is_err());
348 }
349
350 #[test]
351 fn test_spell_check_db_empty_db() {
352 use tokio_test;
353 let client = PubMedClient::new();
354 let result = tokio_test::block_on(client.spell_check_db("asthma", ""));
355 assert!(result.is_err());
356 }
357}