simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit f1647057e14243a9e3d5b0e0151fd2ec6ee92842
parent e57ad4bcdeca11e8c57bf6e874ea0d8adb511c09
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sun,  7 Jun 2026 19:09:13 +0200

feat: use SQLite FTS5 for search instead of LIKE

Query the story_fts table directly via rusqlite for proper
full-text search with ranking. Searches both title and text.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Diffstat:
MCargo.lock | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MCargo.toml | 1+
Msrc/config.rs | 5+++++
Msrc/handlers/search.rs | 50++++++++++++++++++++++++++++++++++++++++++--------
Msrc/state.rs | 2++
5 files changed, 113 insertions(+), 9 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -538,6 +538,18 @@ dependencies = [ ] [[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] name = "fastrand" version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -572,6 +584,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] name = "foreign-types" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -744,11 +762,29 @@ dependencies = [ [[package]] name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1032,7 +1068,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", ] [[package]] @@ -1166,6 +1202,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] +name = "libsqlite3-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1779,6 +1826,20 @@ dependencies = [ ] [[package]] +name = "rusqlite" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] name = "rustc-hash" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2073,6 +2134,7 @@ dependencies = [ "axum-extra", "chrono", "reqwest 0.12.28", + "rusqlite", "serde", "serde_json", "thiserror 2.0.18", diff --git a/Cargo.toml b/Cargo.toml @@ -14,5 +14,6 @@ serde_json = "1" thiserror = "2" tracing = "0.1" reqwest = { version = "0.12", features = ["json"] } +rusqlite = { version = "0.34", features = ["bundled"] } trailbase-client = "0.8" url = "2" diff --git a/src/config.rs b/src/config.rs @@ -4,6 +4,7 @@ pub struct Config { pub trailbase_url: String, pub debug: bool, pub port: u16, + pub db_path: String, } impl Config { @@ -20,10 +21,14 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(8000); + let db_path = env::var("DB_PATH") + .unwrap_or_else(|_| "traildepot/main.db".to_string()); + Self { trailbase_url, debug, port, + db_path, } } } diff --git a/src/handlers/search.rs b/src/handlers/search.rs @@ -7,7 +7,7 @@ use crate::error::AppError; use crate::handlers::auth::get_tokens_from_cookies; use crate::state::AppState; use crate::templates::{ApplicationTemplate, SearchResultsTemplate, SearchTemplate}; -use crate::trailbase; +use crate::trailbase::SearchResult; pub async fn open_search(jar: CookieJar) -> Result<Html<String>, AppError> { let is_logged_in = get_tokens_from_cookies(&jar).is_some(); @@ -25,20 +25,54 @@ pub struct SearchForm { pub async fn search( State(state): State<AppState>, - jar: CookieJar, + _jar: CookieJar, Form(form): Form<SearchForm>, ) -> Result<Html<String>, AppError> { - let client = match get_tokens_from_cookies(&jar) { - Some(tokens) => trailbase::client_with_tokens(&state.trailbase_url, &tokens)?, - None => trailbase::new_client(&state.trailbase_url)?, - }; - let results = if form.search.is_empty() { Vec::new() } else { - trailbase::search_stories(&client, &form.search, 20).await? + let db_path = state.db_path.clone(); + let query = form.search.clone(); + tokio::task::spawn_blocking(move || fts_search(&db_path, &query, 20)) + .await + .map_err(|e| AppError::Api(crate::trailbase::ClientError::Auth(format!("Search task failed: {}", e))))? + .map_err(|e| AppError::Api(crate::trailbase::ClientError::Auth(format!("Search failed: {}", e))))? }; let template = SearchResultsTemplate { results }; Ok(Html(template.render()?)) } + +fn fts_search(db_path: &str, query: &str, limit: usize) -> Result<Vec<SearchResult>, String> { + let conn = rusqlite::Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ).map_err(|e| format!("Failed to open database: {}", e))?; + + let mut stmt = conn.prepare( + "SELECT s.id, s.title, s.text, s.score, s.comment_count + FROM story_fts + JOIN story s ON s.id = story_fts.rowid + WHERE story_fts MATCH ?1 + ORDER BY rank + LIMIT ?2" + ).map_err(|e| format!("Query prepare failed: {}", e))?; + + let rows = stmt.query_map(rusqlite::params![query, limit], |row| { + Ok(SearchResult { + id: row.get(0)?, + title: row.get(1)?, + text: row.get(2)?, + score: row.get(3)?, + comment_count: row.get(4)?, + }) + }).map_err(|e| format!("Query failed: {}", e))?; + + let mut results = Vec::new(); + for row in rows { + if let Ok(r) = row { + results.push(r); + } + } + Ok(results) +} diff --git a/src/state.rs b/src/state.rs @@ -4,6 +4,7 @@ use crate::config::Config; pub struct AppState { pub trailbase_url: String, pub http_client: reqwest::Client, + pub db_path: String, } impl AppState { @@ -11,6 +12,7 @@ impl AppState { Self { trailbase_url: config.trailbase_url.clone(), http_client: reqwest::Client::new(), + db_path: config.db_path.clone(), } } }