hephaestus/crates/heph-core/tests/search.rs
Erich Blume f122c9e6a4
Some checks failed
Build / validate (pull_request) Has been cancelled
feat(cli): heph project list (+ node.list RPC)
Add a list-by-kind primitive so projects (and later tags) can be enumerated.

- core: Store::list_nodes(kind?) — owner-scoped, non-tombstoned, title-sorted;
  sqlite nodes::list; LocalStore/RemoteStore impls
- rpc: node.list {kind?} dispatch
- cli: `heph project list`
- tests: core list_nodes (kind filter, case-insensitive sort, tombstone
  exclusion) + cli project_list (projects only, not tasks)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:50:19 -07:00

95 lines
3.1 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 list_nodes_filters_by_kind_sorts_by_title_and_excludes_tombstoned() {
let mut s = store();
let proj = |s: &mut LocalStore, t: &str| {
s.create_node(NewNode {
kind: NodeKind::Project,
title: t.into(),
body: None,
})
.unwrap()
.id
};
proj(&mut s, "Maintenance");
proj(&mut s, "child"); // lowercase → tests case-insensitive sort
let gone = proj(&mut s, "Archived");
s.create_node(NewNode::doc("Just a doc", "not a project"))
.unwrap();
s.tombstone_node(&gone).unwrap();
// kind=project excludes the doc and the tombstoned project, title-sorted.
let projects = s.list_nodes(Some(NodeKind::Project)).unwrap();
let titles: Vec<&str> = projects.iter().map(|n| n.title.as_str()).collect();
assert_eq!(titles, vec!["child", "Maintenance"]);
// No filter returns every non-tombstoned node (incl. the doc).
let all = s.list_nodes(None).unwrap();
assert!(all.iter().any(|n| n.title == "Just a doc"));
assert!(!all.iter().any(|n| n.id == gone), "tombstoned excluded");
}
#[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);
}