hephaestus/crates/heph-core/tests/search.rs
Erich Blume 5d8ec45c55
Some checks failed
Build / validate (pull_request) Failing after 3s
heph-core: full-text search (FTS5)
Slice query-surface, part 2 (tech-spec §6). Migration v2 adds an FTS5
external-content table over nodes(title, body), kept in sync by
insert/update/delete triggers (with a backfill for existing rows).

- Store::search(query): owner-scoped, tombstones excluded, best-match
  first (FTS5 MATCH + rank). Exposed over RPC; `heph search` and
  `heph journal` CLI commands added.

3 search integration tests (title/body match, edits reflected via trigger,
tombstone exclusion, all insert paths indexed). 79 tests green. This
completes the local feature surface; the remaining slices are the
distributed/auth/nvim layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:43:05 -07:00

65 lines
2 KiB
Rust

//! Full-text search over title + body via FTS5 (tech-spec §6, slice query-surface).
use heph_core::{FixedClock, LocalStore, NewNode, NodeKind, Store};
fn store() -> LocalStore {
LocalStore::open_in_memory(Box::new(FixedClock(1_700_000_000_000))).unwrap()
}
#[test]
fn search_matches_title_and_body() {
let mut s = store();
let roof = s
.create_node(NewNode::doc(
"Roof repair",
"Called the contractor about shingles.",
))
.unwrap();
s.create_node(NewNode::doc("Garden", "Plant tomatoes in spring."))
.unwrap();
// Body term.
let hits = s.search("contractor").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, roof.id);
// Title term.
let hits = s.search("roof").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, roof.id);
// No match.
assert!(s.search("nonexistentword").unwrap().is_empty());
}
#[test]
fn search_reflects_edits_and_excludes_tombstoned() {
let mut s = store();
let n = s.create_node(NewNode::doc("Notes", "alpha")).unwrap();
assert_eq!(s.search("alpha").unwrap().len(), 1);
assert!(s.search("bravo").unwrap().is_empty());
// Edit the body → FTS index follows via the update trigger.
s.update_node(&n.id, None, Some("bravo charlie".into()))
.unwrap();
assert!(s.search("alpha").unwrap().is_empty());
assert_eq!(s.search("bravo").unwrap().len(), 1);
// Tombstoned nodes drop out of results.
s.tombstone_node(&n.id).unwrap();
assert!(s.search("bravo").unwrap().is_empty());
}
#[test]
fn search_indexes_all_node_insert_paths() {
// Nodes created through paths other than `create_node` (here a journal)
// are indexed too, since the FTS triggers fire on every nodes insert.
let mut s = store();
let j = s.journal_open_or_create("2026-05-31").unwrap();
s.update_node(&j.id, None, Some("dentist appointment".into()))
.unwrap();
let hits = s.search("dentist").unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, NodeKind::Journal);
}