pubmed_client/pubmed/client/
espell.rs

1//! ESpell API operations for spell-checking search terms
2
3use crate::error::{PubMedError, Result};
4use crate::pubmed::models::{SpellCheckResult, SpelledQuerySegment};
5use tracing::{debug, info, instrument};
6
7use super::PubMedClient;
8
9impl PubMedClient {
10    /// Check spelling of a search term using the ESpell API
11    ///
12    /// Provides spelling suggestions for terms within a single text query.
13    /// Useful as a preprocessing step before executing actual searches to improve
14    /// search accuracy.
15    ///
16    /// # Arguments
17    ///
18    /// * `term` - The search term to spell-check
19    ///
20    /// # Returns
21    ///
22    /// Returns a `Result<SpellCheckResult>` containing the original query,
23    /// corrected query, and detailed information about which terms were corrected.
24    ///
25    /// # Example
26    ///
27    /// ```no_run
28    /// use pubmed_client::PubMedClient;
29    ///
30    /// #[tokio::main]
31    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
32    ///     let client = PubMedClient::new();
33    ///     let result = client.spell_check("asthmaa OR alergies").await?;
34    ///
35    ///     println!("Original: {}", result.query);
36    ///     println!("Corrected: {}", result.corrected_query);
37    ///
38    ///     if result.has_corrections() {
39    ///         println!("Replacements: {:?}", result.replacements());
40    ///     }
41    ///
42    ///     Ok(())
43    /// }
44    /// ```
45    #[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    /// Check spelling of a search term against a specific database using the ESpell API
51    ///
52    /// Spelling suggestions are database-specific, so use the same database you plan to search.
53    ///
54    /// # Arguments
55    ///
56    /// * `term` - The search term to spell-check
57    /// * `db` - The NCBI database to check against (e.g., "pubmed", "pmc")
58    ///
59    /// # Returns
60    ///
61    /// Returns a `Result<SpellCheckResult>` containing spelling suggestions
62    ///
63    /// # Example
64    ///
65    /// ```no_run
66    /// use pubmed_client::PubMedClient;
67    ///
68    /// #[tokio::main]
69    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
70    ///     let client = PubMedClient::new();
71    ///     let result = client.spell_check_db("fiberblast cell grwth", "pmc").await?;
72    ///     println!("Corrected: {}", result.corrected_query);
73    ///     Ok(())
74    /// }
75    /// ```
76    #[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    /// Parse ESpell XML response into SpellCheckResult
112    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        // Check for error
120        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        // Parse SpelledQuery segments
140        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    /// Parse the interleaved <Original> and <Replaced> elements from SpelledQuery
157    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                    // <Original> comes first
168                    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                    // <Replaced> comes first
182                    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                    // Only <Original> remaining
196                    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                    // Only <Replaced> remaining
210                    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}