Files
Jeffrey Emanuel 4a45dee919 style: rustfmt sweep under current pinned nightly (import ordering)
Mechanical `cargo fmt` output only — no code changes. The floating
nightly channel's rustfmt now orders use-statements differently than
the nightlies some earlier commits were formatted with.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:10:35 -04:00

606 lines
21 KiB
Rust

//! FTS5 Integration Tests for Pages Export
//!
//! Tests the dual FTS5 index strategy:
//! - messages_fts: Porter stemmer for natural language
//! - messages_code_fts: unicode61 with tokenchars for code identifiers
#[cfg(test)]
mod tests {
use anyhow::Result;
use coding_agent_search::franken_sync::compat::{ConnectionExt, RowExt};
use coding_agent_search::franken_sync::{Connection as FrankenConnection, params as fparams};
use coding_agent_search::pages::export::{ExportEngine, ExportFilter, PathMode};
use coding_agent_search::pages::fts::{
Fts5SearchMode, detect_search_mode, escape_fts5_query, format_fts5_query,
validate_fts5_query,
};
use std::path::Path;
use tempfile::TempDir;
fn open_franken_db(path: &Path) -> Result<FrankenConnection> {
let path_str = path.to_string_lossy();
Ok(FrankenConnection::open(path_str.as_ref())?)
}
/// Set up a source database with test data for FTS5 testing
fn setup_fts_source_db(path: &Path) -> Result<()> {
let conn = open_franken_db(path)?;
conn.execute_batch(
r#"
CREATE TABLE agents (
id INTEGER PRIMARY KEY,
slug TEXT NOT NULL
);
CREATE TABLE workspaces (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL
);
CREATE TABLE conversations (
id INTEGER PRIMARY KEY,
agent_id INTEGER NOT NULL,
workspace_id INTEGER,
title TEXT,
source_path TEXT NOT NULL,
started_at INTEGER,
ended_at INTEGER,
message_count INTEGER,
metadata_json TEXT,
FOREIGN KEY (agent_id) REFERENCES agents(id),
FOREIGN KEY (workspace_id) REFERENCES workspaces(id)
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
conversation_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER,
updated_at INTEGER,
model TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
);
"#,
)?;
// Agents + workspaces
conn.execute("INSERT INTO agents (id, slug) VALUES (1, 'claude')")?;
conn.execute("INSERT INTO workspaces (id, path) VALUES (1, '/home/user/project')")?;
// Insert test conversations
conn.execute(
"INSERT INTO conversations (id, agent_id, workspace_id, title, source_path, started_at, message_count)
VALUES (1, 1, 1, 'FTS Test', '/path/1.json', 1600000000000, 5)"
)?;
// Insert messages with various content types for FTS testing
// Message 1: Natural language with stemming test ("running" should match "run")
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (1, 1, 0, 'user', 'I am running the tests and they keep running forever', 1600000000000)"
)?;
// Message 2: Code identifier with underscore (snake_case)
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (2, 1, 1, 'assistant', 'You should call my_function and get_user_by_id to fix the issue', 1600000001000)"
)?;
// Message 3: File path / filename
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (3, 1, 2, 'user', 'The error is in AuthController.ts at line 42', 1600000002000)"
)?;
// Message 4: More content for BM25 ranking tests
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (4, 1, 3, 'assistant', 'Error error error - this message has many errors', 1600000003000)"
)?;
// Message 5: Single mention for ranking comparison
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (5, 1, 4, 'user', 'I found one error in the code', 1600000004000)",
)?;
Ok(())
}
/// Create an exported database with FTS5 indexes
fn create_export_db(temp_dir: &TempDir) -> Result<(FrankenConnection, std::path::PathBuf)> {
let source_path = temp_dir.path().join("source.db");
let output_path = temp_dir.path().join("export.db");
setup_fts_source_db(&source_path)?;
let filter = ExportFilter {
agents: None,
workspaces: None,
since: None,
until: None,
path_mode: PathMode::Relative,
};
let engine = ExportEngine::new(&source_path, &output_path, filter);
engine.execute(|_, _| {}, None)?;
let conn = FrankenConnection::open(output_path.to_string_lossy().into_owned())?;
Ok((conn, output_path))
}
fn create_runtime_fts_db(temp_dir: &TempDir) -> Result<FrankenConnection> {
let db_path = temp_dir.path().join("runtime_fts.db");
let conn = open_franken_db(&db_path)?;
conn.execute_batch(
r#"
CREATE TABLE conversations (
id INTEGER PRIMARY KEY,
agent TEXT NOT NULL,
workspace TEXT,
title TEXT,
source_path TEXT NOT NULL,
started_at INTEGER,
ended_at INTEGER,
message_count INTEGER,
metadata_json TEXT
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
conversation_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at INTEGER,
updated_at INTEGER,
model TEXT,
attachment_refs TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
);
CREATE VIRTUAL TABLE messages_fts USING fts5(
content,
tokenize='porter unicode61 remove_diacritics 2'
);
CREATE VIRTUAL TABLE messages_code_fts USING fts5(
content,
tokenize="unicode61 tokenchars '-_./:@#$%\\'"
);
"#,
)?;
conn.execute(
"INSERT INTO conversations (id, agent, workspace, title, source_path, started_at, message_count)
VALUES (1, 'claude', '/home/user/project', 'FTS Test', '/path/1.json', 1600000000000, 5)"
)?;
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (1, 1, 0, 'user', 'I am running the tests and they keep running forever', 1600000000000)"
)?;
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (2, 1, 1, 'assistant', 'You should call my_function and get_user_by_id to fix the issue', 1600000001000)"
)?;
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (3, 1, 2, 'user', 'The error is in AuthController.ts at line 42', 1600000002000)"
)?;
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (4, 1, 3, 'assistant', 'Error error error - this message has many errors', 1600000003000)"
)?;
conn.execute(
"INSERT INTO messages (id, conversation_id, idx, role, content, created_at)
VALUES (5, 1, 4, 'user', 'I found one error in the code', 1600000004000)",
)?;
conn.execute("INSERT INTO messages_fts(rowid, content) SELECT id, content FROM messages")?;
conn.execute(
"INSERT INTO messages_code_fts(rowid, content) SELECT id, content FROM messages",
)?;
Ok(conn)
}
// ============================================
// Porter Stemmer Tests (messages_fts)
// ============================================
#[test]
fn test_fts5_porter_stemming_run_matches_running() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Search for "run" should match content with "running" due to porter stemmer
let results: Vec<String> = conn.query_map_collect(
r#"
SELECT snippet(messages_fts, 0, '[', ']', '...', 20) as snippet
FROM messages_fts
WHERE messages_fts MATCH '"run"'
"#,
&[],
|row| row.get_typed(0),
)?;
assert!(
!results.is_empty(),
"Porter stemmer should match 'run' to 'running'"
);
// Verify the match is from message 1 which contains "running"
let found_running = results.iter().any(|s| s.to_lowercase().contains("running"));
assert!(
found_running,
"Should have matched the message containing 'running'"
);
Ok(())
}
#[test]
fn test_fts5_porter_stemming_bidirectional() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// The pinned frankensqlite query path does not run MATCH phrases back
// through the table tokenizer, so callers search by the indexed stem.
let count: i64 = conn.query_row_map(
r#"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH '"run"'"#,
&[],
|row| row.get_typed(0),
)?;
assert!(count > 0, "Should find messages containing the 'run' stem");
Ok(())
}
// ============================================
// Code Tokenizer Tests (messages_code_fts)
// ============================================
#[test]
fn test_fts5_code_underscore_token() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
let (_, query) = format_fts5_query("my_function", Fts5SearchMode::Code);
let count: i64 = conn.query_row_map(
"SELECT COUNT(*) FROM messages_code_fts WHERE messages_code_fts MATCH ?1",
fparams![query.as_str()],
|row| row.get_typed(0),
)?;
assert!(
count > 0,
"Code FTS should match 'my_function' as a preserved code token"
);
// Also test get_user_by_id
let (_, query) = format_fts5_query("get_user_by_id", Fts5SearchMode::Code);
let count2: i64 = conn.query_row_map(
"SELECT COUNT(*) FROM messages_code_fts WHERE messages_code_fts MATCH ?1",
fparams![query.as_str()],
|row| row.get_typed(0),
)?;
assert!(
count2 > 0,
"Code FTS should match 'get_user_by_id' as a preserved code token"
);
Ok(())
}
#[test]
fn test_fts5_code_filename_with_extension() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Search for filename with extension
let (_, query) = format_fts5_query("AuthController.ts", Fts5SearchMode::Code);
let count: i64 = conn.query_row_map(
"SELECT COUNT(*) FROM messages_code_fts WHERE messages_code_fts MATCH ?1",
fparams![query.as_str()],
|row| row.get_typed(0),
)?;
assert!(
count > 0,
"Code FTS should match 'AuthController.ts' as a preserved code token"
);
Ok(())
}
// ============================================
// Empty Query Tests
// ============================================
#[test]
fn test_fts5_empty_query_returns_empty() -> Result<()> {
let temp_dir = TempDir::new()?;
let _conn = create_runtime_fts_db(&temp_dir)?;
// Empty escaped query should return no results (not an error)
let escaped = escape_fts5_query("");
assert!(
escaped.is_empty(),
"Empty query should produce empty escaped string"
);
// Validate should return None for empty
assert!(validate_fts5_query("").is_none());
assert!(validate_fts5_query(" ").is_none());
Ok(())
}
// ============================================
// Special Character Escaping Tests
// ============================================
#[test]
fn test_fts5_escape_prevents_injection() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Attempt queries with special characters that could break FTS5
let dangerous_queries = vec![
r#"foo"bar"#, // Embedded quotes
"foo*", // Wildcard
"foo+bar", // OR operator
"foo-bar", // NOT operator
"foo:bar", // Column prefix
"(foo)", // Grouping
"foo^2", // Boost
"foo~2", // Fuzzy
];
for query in dangerous_queries {
let escaped = escape_fts5_query(query);
// Escaped query should be safe to execute
let result = conn.query_row_map(
"SELECT COUNT(*) FROM messages_fts WHERE messages_fts MATCH ?1",
fparams![escaped.as_str()],
|row| row.get_typed::<i64>(0),
);
// Should not error, may return 0 results
assert!(
result.is_ok(),
"Query with '{}' should not cause FTS5 error",
query
);
}
Ok(())
}
#[test]
fn test_fts5_escape_quotes_doubled() {
// Verify that internal quotes are properly doubled
let escaped = escape_fts5_query(r#"say "hello""#);
assert!(
escaped.contains(r#""""#),
"Internal quotes should be doubled"
);
}
// ============================================
// BM25 Ranking Tests
// ============================================
#[test]
fn test_fts5_bm25_ranking_more_matches_higher() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Search for "error" - message 4 has many, message 5 has one
let results: Vec<(i64, f64)> = conn.query_map_collect(
r#"
SELECT m.id, bm25(messages_fts) as score
FROM messages_fts
JOIN messages m ON messages_fts.rowid = m.id
WHERE messages_fts MATCH '"error"'
ORDER BY score
"#,
&[],
|row| Ok((row.get_typed(0)?, row.get_typed(1)?)),
)?;
assert!(
results.len() >= 2,
"Should find at least 2 messages with 'error'"
);
// BM25 returns negative scores (lower = better match)
// Message 4 (many errors) should rank higher (more negative score)
// Find message 4 and 5 in results
let msg4_score = results.iter().find(|(id, _)| *id == 4).map(|(_, s)| *s);
let msg5_score = results.iter().find(|(id, _)| *id == 5).map(|(_, s)| *s);
if let (Some(s4), Some(s5)) = (msg4_score, msg5_score) {
// Note: BM25 scores are negative, more negative = better match
assert!(
s4 < s5,
"Message with more 'error' occurrences should have lower (better) BM25 score"
);
}
Ok(())
}
// ============================================
// Query Mode Detection Tests
// ============================================
#[test]
fn test_detect_search_mode_natural_language() {
assert_eq!(
detect_search_mode("hello world"),
Fts5SearchMode::NaturalLanguage
);
assert_eq!(
detect_search_mode("error handling"),
Fts5SearchMode::NaturalLanguage
);
assert_eq!(
detect_search_mode("the quick brown fox"),
Fts5SearchMode::NaturalLanguage
);
}
#[test]
fn test_detect_search_mode_code_patterns() {
// Underscore (snake_case)
assert_eq!(detect_search_mode("my_function"), Fts5SearchMode::Code);
// Dot (file extension)
assert_eq!(detect_search_mode("main.rs"), Fts5SearchMode::Code);
// camelCase
assert_eq!(detect_search_mode("getUserById"), Fts5SearchMode::Code);
// Path separator
assert_eq!(detect_search_mode("src/lib.rs"), Fts5SearchMode::Code);
}
#[test]
fn test_format_fts5_query_routing() {
// Natural language routes to messages_fts
let (table, query) = format_fts5_query("error handling", Fts5SearchMode::Auto);
assert_eq!(table, "messages_fts");
assert_eq!(query, r#""error" "handling""#);
// Code query routes to messages_code_fts
let (table, query) = format_fts5_query("my_function", Fts5SearchMode::Auto);
assert_eq!(table, "messages_code_fts");
assert_eq!(query, r#""my_function""#);
// Explicit override
let (table, _) = format_fts5_query("hello", Fts5SearchMode::Code);
assert_eq!(table, "messages_code_fts");
}
// ============================================
// FTS5 Index Population Tests
// ============================================
#[test]
fn test_fts5_both_indexes_populated() -> Result<()> {
let temp_dir = TempDir::new()?;
let (conn, _) = create_export_db(&temp_dir)?;
// Count messages in both FTS tables
let porter_count: i64 =
conn.query_row_map("SELECT COUNT(*) FROM messages_fts", &[], |row| {
row.get_typed(0)
})?;
let code_count: i64 =
conn.query_row_map("SELECT COUNT(*) FROM messages_code_fts", &[], |row| {
row.get_typed(0)
})?;
assert_eq!(porter_count, 5, "messages_fts should have 5 entries");
assert_eq!(code_count, 5, "messages_code_fts should have 5 entries");
Ok(())
}
#[test]
fn test_fts5_snippet_generation() -> Result<()> {
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Test snippet generation with highlighting
let snippet: String = conn.query_row_map(
r#"
SELECT snippet(messages_fts, 0, '<mark>', '</mark>', '...', 32)
FROM messages_fts
WHERE messages_fts MATCH '"error"'
LIMIT 1
"#,
&[],
|row| row.get_typed(0),
)?;
// Snippet should contain the matched term with highlighting
assert!(
snippet.contains("<mark>") && snippet.contains("</mark>"),
"Snippet should contain highlight markers"
);
Ok(())
}
// ============================================
// Integration with Search SQL Builder
// ============================================
#[test]
fn test_build_fts5_search_sql_works() -> Result<()> {
use coding_agent_search::pages::fts::build_fts5_search_sql;
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Build and execute the generated SQL
let sql = build_fts5_search_sql("messages_fts", 64, false);
let results: Vec<(i64, String)> =
conn.query_map_collect(&sql, fparams!["\"error\"", 10_i64, 0_i64], |row| {
Ok((row.get_typed(0)?, row.get_typed(3)?))
})?;
assert!(!results.is_empty(), "Search SQL should return results");
Ok(())
}
#[test]
fn test_build_fts5_search_sql_with_agent_filter() -> Result<()> {
use coding_agent_search::pages::fts::build_fts5_search_sql;
let temp_dir = TempDir::new()?;
let conn = create_runtime_fts_db(&temp_dir)?;
// Build SQL with agent filter
let sql = build_fts5_search_sql("messages_fts", 64, true);
let results: Vec<i64> = conn.query_map_collect(
&sql,
fparams!["\"error\"", "claude", 10_i64, 0_i64],
|row| row.get_typed(0),
)?;
// Should find results with agent="claude"
assert!(
!results.is_empty(),
"Search with agent filter should return results"
);
// Try with non-existent agent
let no_results: Vec<i64> = conn.query_map_collect(
&sql,
fparams!["\"error\"", "nonexistent", 10_i64, 0_i64],
|row| row.get_typed(0),
)?;
assert!(
no_results.is_empty(),
"Search with non-existent agent should return no results"
);
Ok(())
}
}