generated from eblume/project-template
Some checks failed
Build / validate (pull_request) Has been cancelled
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>
95 lines
3.1 KiB
Rust
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);
|
|
}
|