Skip to main content

rustbrain_core/
fts.rs

1//! FTS5 query helpers: escaping and safe `MATCH` construction.
2//!
3//! User input must never be passed raw to SQLite FTS5: operators like `AND`,
4//! `OR`, `NEAR`, and bare `"` can change query structure or cause errors.
5
6use crate::error::{BrainError, Result};
7
8/// Escape a user query for SQLite FTS5 `MATCH`.
9///
10/// Each whitespace-separated token is double-quoted so punctuation and FTS
11/// operators in user input become literals. Embedded `"` are doubled per the
12/// FTS5 quoting rules. Empty input returns [`BrainError::FtsQuery`] rather than
13/// matching everything.
14pub fn escape_fts5_query(raw: &str) -> Result<String> {
15    let tokens: Vec<&str> = raw.split_whitespace().filter(|t| !t.is_empty()).collect();
16    if tokens.is_empty() {
17        return Err(BrainError::FtsQuery(
18            "query must contain at least one non-whitespace token".into(),
19        ));
20    }
21
22    let escaped = tokens
23        .into_iter()
24        .map(|t| {
25            // FTS5 escapes embedded double-quotes by doubling them.
26            let q = t.replace('"', "\"\"");
27            format!("\"{q}\"")
28        })
29        .collect::<Vec<_>>()
30        .join(" ");
31
32    Ok(escaped)
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38
39    #[test]
40    fn escapes_and_quotes_tokens() {
41        // Input token `"consensus"` doubles internal quotes, then outer-quotes:
42        // " + ""consensus"" + "  →  """consensus"""
43        let q = escape_fts5_query(r#"raft "consensus""#).unwrap();
44        assert_eq!(q, r#""raft" """consensus""""#);
45    }
46
47    #[test]
48    fn rejects_empty() {
49        assert!(escape_fts5_query("   ").is_err());
50    }
51
52    #[test]
53    fn operators_become_literals() {
54        // AND/OR would be operators unquoted; we force literals.
55        let q = escape_fts5_query("foo AND bar").unwrap();
56        assert_eq!(q, r#""foo" "AND" "bar""#);
57    }
58}