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}