Full-text search with FTS5
Step 8 of 13. Step 7 closed the write loop: notes open in the editor and save themselves after a debounce. But the only way to find anything is to read the list. This step puts a search box over the notes — type “milk”, see every note that mentions milk with the hit marked, click a result to open it. The engine is SQLite’s FTS5: a virtual table that indexes the notes, a MATCH query that ranks hits, and two built-in functions that mark the matched words. It starts from tauri-ch06, the tag step 7 left behind.
No new dependencies
Section titled “No new dependencies”FTS5 is part of SQLite, and the bundled feature of rusqlite builds SQLite with it enabled — you can check with SELECT fts5('x'), or just trust the migration in the next section to fail loudly if it were missing. src-tauri/Cargo.toml keeps its step-5 content:
[dependencies]tauri = { version = "2", features = [] }tauri-plugin-opener = "2"serde = { version = "1", features = ["derive"] }serde_json = "1"rusqlite = { version = "0.37", features = ["bundled"] }A second migration
Section titled “A second migration”CREATE VIRTUAL TABLE notes_fts USING fts5(title, body) makes a table whose columns are an inverted index rather than rows. Under the hood SQLite creates shadow tables — notes_fts_data, notes_fts_idx and friends — that hold the index; you never touch them directly. The virtual table has a rowid, and the plan is to keep it equal to notes.id, so every hit can jump back to the real note.
The FTS table stores its own copy of the indexed text. That costs disk space and one extra write per change, and in exchange the index stays a plain SQLite object you can query, rebuild, and inspect. Databases like Postgres call this pattern an index that “stores” the document; SQLite just calls it a regular FTS5 table.
The text copy has to track the real one, and triggers do that: after every INSERT, UPDATE, or DELETE on notes, the same change lands in notes_fts. This is why nothing in create_note, list_notes, or step 7’s debounced update_note needs to know that search exists — the autosave you already built keeps the index fresh for free. The DELETE trigger has nothing to do yet, since rustnote cannot delete notes; it is here so the index cannot drift the day delete arrives.
Add the migration after the existing one in src-tauri/src/lib.rs:
use rusqlite::Connection;
const MIGRATION_1: &str = "CREATE TABLE notes ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL);";
const MIGRATION_2: &str = "CREATE VIRTUAL TABLE notes_fts USING fts5(title, body);INSERT INTO notes_fts(rowid, title, body) SELECT id, title, body FROM notes;CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);END;CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN DELETE FROM notes_fts WHERE rowid = old.id;END;CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN DELETE FROM notes_fts WHERE rowid = old.id; INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);END;";
fn run_migrations(conn: &Connection) -> rusqlite::Result<()> { let version: i64 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; if version < 1 { conn.execute_batch(MIGRATION_1)?; conn.pragma_update(None, "user_version", 1)?; } if version < 2 { conn.execute_batch(MIGRATION_2)?; conn.pragma_update(None, "user_version", 2)?; } Ok(())}(MIGRATION_1 is unchanged from step 5; it is in the block so the function compiles on its own. In lib.rs each use appears once, at the top.)
The INSERT ... SELECT backfill is what makes the version bump safe on a database that already has notes. Your database from step 7 sits at user_version 1, so on first launch the new branch runs, creates the virtual table, and copies every existing note into it. A fresh install runs both migrations in order and backfills an empty table, which costs nothing.
The query that does the work looks like this, over two notes seeded into a bare connection:
use rusqlite::Connection;
fn main() -> Result<(), rusqlite::Error> { let conn = Connection::open_in_memory()?; conn.execute_batch( "CREATE TABLE notes ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL ); INSERT INTO notes (title, body, created_at) VALUES ('Shopping list', 'milk, eggs, and a loaf of bread', 1), ('Article ideas', 'an essay about search engines and ranking', 2); CREATE VIRTUAL TABLE notes_fts USING fts5(title, body); INSERT INTO notes_fts(rowid, title, body) SELECT id, title, body FROM notes;", )?;
let sql = "SELECT notes.id, highlight(notes_fts, 0, '«', '»') AS title, snippet(notes_fts, 1, '«', '»', '…', 12) AS snippet FROM notes_fts JOIN notes ON notes.id = notes_fts.rowid WHERE notes_fts MATCH ?1 ORDER BY rank";
for q in ["ideas", "milk", "search engines", "mil*", "mil", "zebra"] { let mut stmt = conn.prepare(sql)?; let rows: Vec<(i64, String, String)> = stmt .query_map([q], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? .collect::<Result<_, _>>()?; println!("query {q:?}:"); for (id, title, snippet) in &rows { println!(" #{id} {title} | {snippet}"); } } Ok(())}query "ideas": #2 Article «ideas» | an essay about search engines and rankingquery "milk": #1 Shopping list | «milk», eggs, and a loaf of breadquery "search engines": #2 Article ideas | an essay about «search» «engines» and rankingquery "mil*": #1 Shopping list | «milk», eggs, and a loaf of breadquery "mil":query "zebra":Five behaviours worth naming. Matching is by whole token — mil finds nothing because “milk” is a different token, and mil* is a prefix query that finds it. Space between words means AND: search engines returns only the note with both. ORDER BY rank sorts by how well each row matches, using FTS5’s default bm25 weighting. A miss returns an empty result, not an error. And the tokens are matched case-insensitively with punctuation stripped — “milk,” in the note body matches milk.
highlight(notes_fts, 0, '«', '»') returns column 0 (the title) with every hit wrapped in the two marker strings. snippet(notes_fts, 1, '«', '»', '…', 12) does the same for column 1 (the body), but compresses it: at most 12 tokens around the hits, … where text was cut. Both take the column index as their second argument, which is why the SELECT spells out AS title and AS snippet instead of relying on position in the result row. When the hit is in the title, the snippet is just the body with no markers — the query “ideas” row shows that.
The JOIN notes ON notes.id = notes_fts.rowid is the payoff of the rowid discipline: it converts an index hit back into the note with its real id, which the front end needs to open the note in the editor.
User input is not query syntax
Section titled “User input is not query syntax”Everything above sends hand-written strings to MATCH. A search box sends whatever the user typed, and FTS5 parses that as its own query language. Two examples of what that means:
use rusqlite::Connection;
fn quote_tokens(query: &str) -> String { query .split_whitespace() .map(|token| format!("\"{}\"", token.replace('"', "\"\""))) .collect::<Vec<_>>() .join(" ")}
fn main() -> Result<(), rusqlite::Error> { let conn = Connection::open_in_memory()?; conn.execute_batch( "CREATE VIRTUAL TABLE notes_fts USING fts5(title, body); INSERT INTO notes_fts(rowid, title, body) VALUES (1, 'Shopping list', 'milk, eggs, and a loaf of bread');", )?;
for raw in ["milk (eggs", "well-known"] { let result: Result<Vec<i64>, _> = conn .prepare("SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?1")? .query_map([raw], |row| row.get(0))? .collect(); match result { Ok(rows) => println!("{raw:?} matched {rows:?}"), Err(e) => println!("{raw:?}: {e}"), } }
let quoted = quote_tokens("milk (eggs"); println!("MATCH {quoted:?}"); let mut stmt = conn.prepare( "SELECT rowid, snippet(notes_fts, 1, '«', '»', '…', 12) FROM notes_fts WHERE notes_fts MATCH ?1", )?; let rows: Vec<(i64, String)> = stmt .query_map(["ed], |row| Ok((row.get(0)?, row.get(1)?)))? .collect::<Result<_, _>>()?; println!("{rows:?}"); Ok(())}"milk (eggs": fts5: syntax error near """well-known": no such column: knownMATCH "\"milk\" \"(eggs\""[(1, "«milk», «eggs», and a loaf of bread")]The unbalanced parenthesis is a syntax error — fts5: syntax error near "", which reaches your handler as the Err of a Result. The hyphen is worse and quieter to diagnose: in FTS5’s grammar column:filter narrows a search to one column, so well-known parses as “the word known in a column named well”, and there is no column named well.
The fix is to stop treating the user’s text as query syntax. quote_tokens splits it on whitespace, wraps each word in double quotes, and doubles any quote the user actually typed — that is FTS5’s escaping rule for quoted strings. Quoted tokens carry no operators, so milk (eggs becomes the two-word AND query "milk" "(eggs" and matches the note that has both. The cost: *, AND, and column filters stop working in the search box. If you want prefix search back, append the star outside the quotes — "mil"* is a legal prefix query.
The search command
Section titled “The search command”The command follows the house conventions from steps 5-7: State<'_, Mutex<Connection>>, map_err(|e| e.to_string()) on every fallible step, and Result because the command is async with borrowed parameters. An empty query returns no hits instead of reaching SQL with an empty string, which is itself a syntax error. This goes after update_note in src-tauri/src/lib.rs:
use std::sync::Mutex;
use rusqlite::Connection;use serde::Serialize;use tauri::State;
#[derive(Serialize)]pub struct SearchHit { id: i64, title: String, snippet: String,}
fn quote_tokens(query: &str) -> String { query .split_whitespace() .map(|token| format!("\"{}\"", token.replace('"', "\"\""))) .collect::<Vec<_>>() .join(" ")}
#[tauri::command]async fn search_notes( state: State<'_, Mutex<Connection>>, query: String,) -> Result<Vec<SearchHit>, String> { let trimmed = query.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } let match_query = quote_tokens(trimmed); let conn = state.lock().map_err(|e| e.to_string())?; let mut stmt = conn .prepare( "SELECT notes.id, highlight(notes_fts, 0, '«', '»') AS title, snippet(notes_fts, 1, '«', '»', '…', 12) AS snippet FROM notes_fts JOIN notes ON notes.id = notes_fts.rowid WHERE notes_fts MATCH ?1 ORDER BY rank", ) .map_err(|e| e.to_string())?; let hits = stmt .query_map([&match_query], |row| { Ok(SearchHit { id: row.get(0)?, title: row.get(1)?, snippet: row.get(2)?, }) }) .map_err(|e| e.to_string())? .collect::<Result<Vec<SearchHit>, _>>() .map_err(|e| e.to_string())?; Ok(hits)}(The use lines are what the block needs to compile on its own; in lib.rs each appears once, at the top.)
SearchHit returns the marked-up title and snippet as plain strings — the markers cross IPC as ordinary characters, and the front end turns them into visual emphasis. Register the command with the others:
.invoke_handler(tauri::generate_handler![ add, divide, stats, create_note, list_notes, update_note, search_notes ])The front end
Section titled “The front end”src/App.tsx gains three pieces. First, the hit type and a small component that turns «…» markers into <mark> elements — the capturing group in the split regex makes split keep the delimited pieces, so each hit word can be wrapped and the rest left alone:
type SearchHit = { id: number; title: string; snippet: string };
function Highlighted({ text }: { text: string }) { const parts = text.split(/(«[^»]*»)/g); return ( <> {parts.map((part, i) => part.startsWith("«") ? ( <mark key={i}>{part.slice(1, -1)}</mark> ) : ( <span key={i}>{part}</span> ) )} </> );}Second, the state and the call — hits is null when no search has run, an empty array when one ran and found nothing, and the distinction decides whether the results list renders at all:
const [query, setQuery] = useState(""); const [hits, setHits] = useState<SearchHit[] | null>(null); async function search() { try { if (query.trim() === "") { setHits(null); return; } setHits(await invoke<SearchHit[]>("search_notes", { query })); } catch (e) { setSaved(`Error: ${e}`); } }Third, a form between the status line and the notes list. It is a form rather than a bare input so Enter submits, and the Clear button is type="button" so it does not submit by accident. A hit opens its note by looking the full Note up in the notes state you already have — the hit only carries the marked title and snippet, not the body the editor needs:
<form className="row" onSubmit={(e) => { e.preventDefault(); search(); }} > <input value={query} onChange={(e) => setQuery(e.currentTarget.value)} aria-label="search notes" /> <button type="submit">Search</button> {hits !== null && ( <button type="button" onClick={() => { setHits(null); setQuery(""); }} > Clear </button> )} </form> {hits !== null && ( <ul className="hits"> {hits.map((hit) => ( <li key={hit.id} onClick={() => { const note = notes.find((n) => n.id === hit.id); if (note) startEdit(note); }} > <Highlighted text={hit.title} /> <br /> <small> <Highlighted text={hit.snippet} /> </small> </li> ))} {hits.length === 0 && <li>no matches</li>} </ul> )}One rule in src/App.css makes the markers visible — the browser’s default <mark> yellow is close enough to invisible on this page’s near-white inputs:
mark { background-color: #ffe58f; border-radius: 3px; padding: 0 2px;}See it work
Section titled “See it work”Seed the database sqlite3 can see, as in steps 6 and 7:
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "DELETE FROM notes; INSERT INTO notes (id, title, body, created_at) VALUES (1, 'Shopping list', 'milk, eggs, and a loaf of bread', 1792400000), (2, 'Article ideas', 'an essay about search engines and ranking', 1792400500), (3, 'Trip', 'pack the charger and passport', 1792401000);"$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "SELECT id, title FROM notes ORDER BY id;"1|Shopping list2|Article ideas3|TripRun npm run tauri dev. The window opens at user_version 1, migration 2 runs, and the database comes out upgraded — the evidence is checkable from the outside:
$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "PRAGMA user_version;"2$ sqlite3 "$HOME/Library/Application Support/com.rustnote.app/rustnote.db" "SELECT name FROM sqlite_master WHERE name LIKE 'notes_fts%' ORDER BY name;"notesnotes_ftsnotes_fts_confignotes_fts_contentnotes_fts_datanotes_fts_docsizenotes_fts_idxTo watch the searches cross IPC, add a temporary eprintln! at the end of search_notes,
for hit in &hits { eprintln!("SEARCH {} -> #{} {} | {}", match_query, hit.id, hit.title, hit.snippet);}plus a throwaway useEffect next to the one that calls loadNotes(), firing six queries on mount:
(async () => { for (const q of ["milk", "search engines", "ideas", "mil", "milk (eggs", "well-known"]) { try { await invoke("search_notes", { query: q }); } catch (e) { console.error(e); } }})();The terminal npm run tauri dev runs in shows what each query became and what came back — dev-mode React StrictMode runs effects twice, so each line prints twice; unique lines shown. mil and well-known print nothing: quoted, both are two-word queries that match nothing:
SEARCH "milk" -> #1 Shopping list | «milk», eggs, and a loaf of breadSEARCH "search" "engines" -> #2 Article ideas | an essay about «search» «engines» and rankingSEARCH "ideas" -> #2 Article «ideas» | an essay about search engines and rankingSEARCH "milk" "(eggs" -> #1 Shopping list | «milk», «eggs», and a loaf of breadIn the window: type milk in the search box, press Enter, and one hit appears with the word shaded in both lines. Click it — the editor opens “Shopping list”. Type well-known and you get “no matches” instead of an error box, because the quoting turned the unparseable input into an honest miss. Delete the probe afterwards; it is scaffolding, not a feature.
Where the code stands
Section titled “Where the code stands”The step is tagged tauri-ch07 in the examples repository:
git clone https://github.com/hackingwithrust/examplescd examples/tauri-desktop-appgit checkout tauri-ch07npm installnpm run tauri devIf you have been following along, your tree is current: this step touched src-tauri/src/lib.rs, src/App.tsx, and src/App.css.
Step 9 brings files in from outside the app: the file-system and dialog plugins, importing .md files as notes, and drag-and-drop. The track overview keeps the full list current as articles land.