Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/session_recall/commands/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,29 @@ def sanitize_fts5_query(raw: str) -> str | None:
"""Escape FTS5 special characters and add prefix matching.

Returns None for empty/whitespace-only queries.
Strategy: split on whitespace, quote each token that contains
special chars, append * for prefix matching on every token.
Strategy: split on whitespace. Plain tokens get * for prefix matching.
Tokens containing special chars (hyphens, dots, slashes, ...) are split
on those chars into the subtokens unicode61 actually indexes, and
searched as a phrase with prefix matching on the last subtoken —
so `session-recall` becomes `"session recall"*` and matches without
the caller needing to know how the tokenizer splits.
"""
stripped = raw.strip()
if not stripped:
return None
tokens = stripped.split()
safe_tokens = []
for tok in tokens:
escaped = tok.replace('"', '""')
if _FTS5_SPECIAL.search(tok):
safe_tokens.append(f'"{escaped}"')
subtokens = [s for s in _FTS5_SPECIAL.split(tok) if s]
if not subtokens:
continue
phrase = " ".join(s.replace('"', '""') for s in subtokens)
safe_tokens.append(f'"{phrase}"*')
else:
safe_tokens.append(f"{escaped}*")
safe_tokens.append(f'{tok.replace(chr(34), chr(34) * 2)}*')
if not safe_tokens:
return None
return " ".join(safe_tokens)


Expand Down
34 changes: 23 additions & 11 deletions src/session_recall/tests/test_search_sanitize.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,38 @@


class TestSanitizeFTS5Query:
"""Cover all crash cases from the adversarial gap analysis."""
"""Cover all crash cases from the adversarial gap analysis.

Tokens containing FTS5 special characters are split into the subtokens
unicode61 actually indexes and searched as phrase-prefix queries, so
`session-recall` becomes `"session recall"*` and matches without the
caller needing to know how the tokenizer splits.
"""

def test_dot_in_filename(self):
assert sanitize_fts5_query("CLAUDE.md") == '"CLAUDE.md"'
assert sanitize_fts5_query("CLAUDE.md") == '"CLAUDE md"*'

def test_dot_in_python_file(self):
assert sanitize_fts5_query("test.py") == '"test.py"'
assert sanitize_fts5_query("test.py") == '"test py"*'

def test_hyphen_treated_as_not(self):
assert sanitize_fts5_query("session-recall") == '"session-recall"'
def test_hyphen_split_to_phrase_prefix(self):
assert sanitize_fts5_query("session-recall") == '"session recall"*'

def test_parentheses_grouping(self):
assert sanitize_fts5_query("function()") == '"function()"'
assert sanitize_fts5_query("function()") == '"function"*'

def test_empty_string_returns_none(self):
assert sanitize_fts5_query("") is None

def test_whitespace_only_returns_none(self):
assert sanitize_fts5_query(" ") is None

def test_all_special_chars_returns_none(self):
assert sanitize_fts5_query("!!! ---") is None

def test_pure_special_token_dropped(self):
assert sanitize_fts5_query("--- foo") == "foo*"

def test_normal_word_gets_prefix_wildcard(self):
assert sanitize_fts5_query("OAuth") == "OAuth*"

Expand All @@ -31,20 +43,20 @@ def test_multi_word_all_get_prefix(self):

def test_mixed_special_and_normal(self):
result = sanitize_fts5_query("fix CLAUDE.md now")
assert result == 'fix* "CLAUDE.md" now*'
assert result == 'fix* "CLAUDE md"* now*'

def test_double_quotes_escaped(self):
def test_double_quotes_stripped_to_phrase(self):
result = sanitize_fts5_query('say "hello"')
assert '""hello""' in result
assert result == 'say* "hello"*'

def test_asterisk_special(self):
result = sanitize_fts5_query("*.py")
assert result.startswith('"')

def test_colon_special(self):
result = sanitize_fts5_query("key:value")
assert result == '"key:value"'
assert result == '"key value"*'

def test_slash_in_path(self):
result = sanitize_fts5_query("src/main.py")
assert result == '"src/main.py"'
assert result == '"src main py"*'