quick_search/crates/quicksearch-core/tests/cascade.rs

1872 lines
67 KiB
Rust
Raw Permalink Normal View History

//! Integration tests for the ranked search cascade and the streaming search
//! service, against real temp databases.
use std::path::{Path, MAIN_SEPARATOR, MAIN_SEPARATOR_STR};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use quicksearch_core::config::ProcessingConfig;
use quicksearch_core::db::open_or_recreate;
use quicksearch_core::db::repo::{insert_file, store_pending_text, NewFile};
use quicksearch_core::mime::FileType;
use quicksearch_core::query::split::split_for_cascade;
use quicksearch_core::search::{
cascade, MatchField, SearchHit, SearchOptions, SearchService, SearchUpdate,
};
use quicksearch_core::testutil::{zstd_of, Scratch};
struct Seeder {
conn: rusqlite::Connection,
}
impl Seeder {
/// An index in the default layout: compact, without positions.
fn new(path: &Path) -> Seeder {
Seeder::with_positions(path, false)
}
fn with_positions(path: &Path, positions: bool) -> Seeder {
let processing = ProcessingConfig {
compact_text_index: !positions,
..Default::default()
};
Seeder {
conn: open_or_recreate(path.to_str().unwrap(), &processing).unwrap(),
}
}
/// Insert a file; `text: Some(..)` also content-indexes it. `dir` has no
/// trailing separator; the stored parent always carries one.
fn add(&mut self, name: &str, dir: &str, mtime: u64, text: Option<&str>) -> i64 {
self.insert(name, &format!("{}/", dir), mtime, text)
}
/// [`Seeder::add`] with `dir` stored in the platform's spelling, as the
/// indexer stores it: for the tests whose `path:` and `folder:` filters
/// the translator respells so (`/` is `\` on Windows).
fn add_native(&mut self, name: &str, dir: &str, mtime: u64, text: Option<&str>) -> i64 {
let parent = format!("{}{}", native(dir), MAIN_SEPARATOR);
self.insert(name, &parent, mtime, text)
}
fn insert(&mut self, name: &str, parent: &str, mtime: u64, text: Option<&str>) -> i64 {
let tx = self.conn.transaction().unwrap();
let id = insert_file(
&tx,
&NewFile {
name,
parent,
size: 42,
mtime,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
needs_content: true,
},
)
.unwrap()
.expect("unique path");
if let Some(text) = text {
store_pending_text(&tx, id, text, zstd_of(text).as_deref()).unwrap();
}
tx.commit().unwrap();
id
}
fn done(self) -> rusqlite::Connection {
self.conn
}
}
/// `dir`, written with `/`, in the platform's separator.
fn native(dir: &str) -> String {
dir.replace('/', MAIN_SEPARATOR_STR)
}
/// Run a search and return its hits in rank order; arrival order is scan
/// order. Use [`run_collect_batches`] for the stream itself.
fn run_collect(
conn: &rusqlite::Connection,
input: &str,
options: &SearchOptions,
) -> (Vec<SearchHit>, cascade::Outcome) {
let (batches, outcome) = run_collect_batches(conn, input, options);
let mut hits: Vec<SearchHit> = batches.into_iter().flatten().collect();
hits.sort_by(|a, b| {
a.rank
.partial_cmp(&b.rank)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.path.cmp(&b.path))
});
(hits, outcome)
}
/// The raw batch stream, one `Vec` per sink call.
fn run_collect_batches(
conn: &rusqlite::Connection,
input: &str,
options: &SearchOptions,
) -> (Vec<Vec<SearchHit>>, cascade::Outcome) {
let split = split_for_cascade(input).expect("split");
let latest = AtomicU64::new(7);
let mut batches = Vec::new();
let outcome = cascade::run(conn, &split, options, 7, &latest, &mut |batch| {
batches.push(batch)
})
.expect("cascade run")
.expect("not cancelled");
(batches, outcome)
}
/// Each hit's file id, in order.
fn ids(hits: &[SearchHit]) -> Vec<i64> {
hits.iter().map(|h| h.file_id).collect()
}
/// Each hit's file id and stage, in order.
fn staged(hits: &[SearchHit]) -> Vec<(i64, u8)> {
hits.iter().map(|h| (h.file_id, h.stage)).collect()
}
/// `ids` sorted, for tests that pin which files match but not their order.
fn sorted(mut ids: Vec<i64>) -> Vec<i64> {
ids.sort();
ids
}
fn fuzzy_options() -> SearchOptions {
SearchOptions {
fuzzy: true,
..SearchOptions::default()
}
}
fn fuzzy_options_with_edits(max_edits: usize) -> SearchOptions {
SearchOptions {
fuzzy: true,
fuzzy_max_edits: max_edits,
..SearchOptions::default()
}
}
#[test]
fn rank_classification_across_all_stages() {
let (_dir, p) = Scratch::db("ranks");
let mut s = Seeder::new(&p);
let rank1 = s.add("Report", "/a", 1, None);
let rank2 = s.add("report", "/b", 2, None);
let rank3 = s.add("Quarterly_Report.txt", "/c", 3, None);
let rank4 = s.add("quarterly_report.txt", "/d", 4, None);
let rank5 = s.add("notes-cs.txt", "/e", 5, Some("the Report was filed today"));
let rank6 = s.add("notes-ci.txt", "/f", 6, Some("the report was filed today"));
let rank7 = s.add("Reprot.txt", "/g", 7, None); // 2 substitutions
let rank8 = s.add("body-fuzzy.txt", "/h", 8, Some("the reoprt went missing"));
let rank9 = s.add("alpha.bin", "/Report-archive", 9, None);
let rank10 = s.add("beta.bin", "/report-archive", 10, None);
let rank11 = s.add("gamma.bin", "/Reprot-archive", 11, None);
let _miss = s.add("unrelated.bin", "/z", 12, Some("nothing to see"));
let conn = s.done();
let (hits, outcome) = run_collect(&conn, "Report", &fuzzy_options());
assert_eq!(
staged(&hits),
vec![
(rank1, 1),
(rank2, 2),
(rank3, 3),
(rank4, 4),
(rank5, 5),
(rank6, 6),
(rank7, 7),
(rank8, 8),
(rank9, 9),
(rank10, 10),
(rank11, 11),
],
"each fixture lands at its designed rank, in order"
);
assert_eq!(outcome.total, 11);
assert!(!outcome.limited);
for pair in hits.windows(2) {
assert!(
pair[0].rank <= pair[1].rank,
"ranks must never decrease: {} then {}",
pair[0].rank,
pair[1].rank
);
}
for h in hits
.iter()
.filter(|h| h.stage == 5 || h.stage == 6 || h.stage == 8)
{
let snip = h.snippet.as_ref().expect("full-text hit has a snippet");
for &(a, b) in &snip.ranges {
assert!(a < b && b <= snip.window.len());
}
}
}
/// Found through the `parent LIKE` half of the one-piece prefilter; see
/// [`a_wildcard_spanning_the_directory_boundary_is_still_found`] for the rest.
#[test]
fn path_substring_tiers_split_by_case() {
let (_dir, p) = Scratch::db("pathcase");
let mut s = Seeder::new(&p);
let exact = s.add("a.bin", "/srv/Vacation/raw", 1, None);
let anycase = s.add("b.bin", "/vacation-2023", 2, None);
let _miss = s.add("c.bin", "/holiday", 3, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "Vacation", &SearchOptions::default());
assert_eq!(
staged(&hits),
vec![(exact, 9), (anycase, 10)],
"exact-case path matches outrank any-case ones"
);
let hit = &hits[0];
let snip = hit.snippet.as_ref().expect("path hits mark the match");
assert_eq!(snip.window, hit.path, "the window is the whole path");
assert_eq!(snip.ranges.len(), 1);
let (a, b) = snip.ranges[0];
assert_eq!(&snip.window[a..b], "Vacation");
assert!(!snip.truncated_start && !snip.truncated_end);
}
#[test]
fn path_match_is_deduped_against_a_better_name_match() {
let (_dir, p) = Scratch::db("pathdedup");
let mut s = Seeder::new(&p);
let star = s.add("Budget.txt", "/Budget/2024", 1, None);
let conn = s.done();
let (hits, outcome) = run_collect(&conn, "Budget", &fuzzy_options());
assert_eq!(outcome.total, 1);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file_id, star);
assert_eq!(hits[0].stage, 3, "the name tier wins");
}
#[test]
fn short_terms_respect_the_three_char_trigram_floor() {
let (_dir, p) = Scratch::db("pathfloor");
let mut s = Seeder::new(&p);
let dir_only = s.add("z.bin", "/abcdir", 1, None);
let name_hit = s.add("ab.txt", "/d", 2, None);
let _text_only = s.add("body.txt", "/d", 3, Some("ab ab ab"));
let conn = s.done();
let (short, _) = run_collect(&conn, "ab", &fuzzy_options());
assert_eq!(
ids(&short),
vec![name_hit],
"2-char term: filename stages only (text, path and fuzzy all skipped)"
);
// Fuzzy is off here, so `ab.txt` (1 edit from `abc`) stays out of it.
let (long, _) = run_collect(&conn, "abc", &SearchOptions::default());
assert_eq!(
staged(&long),
vec![(dir_only, 9)],
"3-char term: the directory match surfaces"
);
}
#[test]
fn term_with_separator_matches_across_the_path() {
let (_dir, p) = Scratch::db("pathsep");
let mut s = Seeder::new(&p);
let nested = s.add("report-final.txt", "/home/docs", 1, None);
let _elsewhere = s.add("report-final.txt", "/home/other", 2, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "docs/report", &SearchOptions::default());
assert_eq!(
staged(&hits),
vec![(nested, 9)],
"a term spanning a separator can only match the full path"
);
}
#[test]
fn fuzzy_path_tier_requires_the_fuzzy_flag() {
let (_dir, p) = Scratch::db("fuzzypath");
let mut s = Seeder::new(&p);
let typo_dir = s.add("gamma.bin", "/Reprot-archive", 1, None);
let conn = s.done();
let (off, _) = run_collect(&conn, "Report", &SearchOptions::default());
assert!(off.is_empty(), "no fuzzy stages without the flag");
let (on, _) = run_collect(&conn, "Report", &fuzzy_options());
assert_eq!(staged(&on), vec![(typo_dir, 11)]);
assert!((on[0].rank - 11.2).abs() < 1e-9, "2 edits adds 0.2");
}
#[test]
fn fuzzy_max_edits_widens_and_narrows_the_budget() {
let (_dir, p) = Scratch::db("fuzzybudget");
let mut s = Seeder::new(&p);
let two_edits = s.add("quartrely.txt", "/d", 1, None);
let three_edits = s.add("quxxxerly.txt", "/d", 2, None);
let conn = s.done();
let (default, _) = run_collect(&conn, "quarterly", &fuzzy_options());
assert_eq!(
ids(&default),
vec![two_edits],
"the default budget of 2 can't reach a 3-edit typo"
);
assert!((default[0].rank - 7.2).abs() < 1e-9);
let (widened, _) = run_collect(&conn, "quarterly", &fuzzy_options_with_edits(3));
assert_eq!(
ids(&widened),
vec![two_edits, three_edits],
"raising the cap admits the 3-edit typo, ranked after the closer one"
);
assert!((widened[1].rank - 7.3).abs() < 1e-9);
let (strict, _) = run_collect(&conn, "quarterly", &fuzzy_options_with_edits(1));
assert!(strict.is_empty(), "a cap of 1 rejects both typos");
let (off, _) = run_collect(&conn, "quarterly", &fuzzy_options_with_edits(0));
assert!(off.is_empty(), "a cap of 0 disables the fuzzy stages");
}
#[test]
fn the_fuzzy_budget_stops_at_the_hard_cap() {
use quicksearch_core::search::fuzzy::MAX_FUZZY_EDITS;
let (_dir, p) = Scratch::db("fuzzycap");
let mut s = Seeder::new(&p);
// Against a 30-character term, whose length alone earns ten edits.
let at_cap = s.add("abcdefghijklmnopqrstuv########", "/d", 1, None);
s.add("abcdefghijklmnopqrstu#########", "/d", 2, None);
let conn = s.done();
for setting in [MAX_FUZZY_EDITS, 10, usize::MAX] {
let (hits, _) = run_collect(
&conn,
"abcdefghijklmnopqrstuvwxyz0123",
&fuzzy_options_with_edits(setting),
);
assert_eq!(ids(&hits), vec![at_cap], "setting {setting}");
assert!((hits[0].rank - 7.8).abs() < 1e-9, "rank {}", hits[0].rank);
assert_eq!(hits[0].stage, 7);
assert_eq!(hits[0].match_field(), MatchField::Name);
}
}
#[test]
fn dedup_keeps_best_rank() {
let (_dir, p) = Scratch::db("dedup");
let mut s = Seeder::new(&p);
let star = s.add("Budget", "/a", 1, Some("Budget Budget Budget"));
let conn = s.done();
let (hits, outcome) = run_collect(&conn, "Budget", &fuzzy_options());
assert_eq!(outcome.total, 1);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].file_id, star);
assert_eq!(hits[0].stage, 1);
}
#[test]
fn occurrence_counts_order_within_rank() {
let (_dir, p) = Scratch::db("frac");
let mut s = Seeder::new(&p);
let one = s.add("one.txt", "/d", 1, Some("zebra"));
let three = s.add("three.txt", "/d", 2, Some("zebra zebra zebra"));
let thousand = s.add("thousand.txt", "/d", 3, Some(&"zebra ".repeat(1500)));
let conn = s.done();
let (hits, _) = run_collect(&conn, "zebra", &SearchOptions::default());
assert_eq!(
ids(&hits),
vec![thousand, three, one],
"more occurrences sorts earlier within the rank"
);
assert_eq!(hits[0].rank, 5.0, "1000+ occurrences adds zero");
assert!((hits[1].rank - 5.997).abs() < 1e-9);
assert!((hits[2].rank - 5.999).abs() < 1e-9);
}
#[test]
fn like_metacharacters_are_literal() {
let (_dir, p) = Scratch::db("like");
let mut s = Seeder::new(&p);
let percent = s.add("100%.txt", "/d", 1, None);
let underscore = s.add("100_.txt", "/d", 2, None);
let contains = s.add("x100y.txt", "/d", 3, None);
// No "100" at all: only leaked `%`/`_` semantics could admit it.
let _decoy = s.add("1x0y.txt", "/d", 4, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "100%", &SearchOptions::default());
assert_eq!(
ids(&hits),
vec![percent],
"% in the term must not act as a wildcard"
);
let (hits, _) = run_collect(&conn, "100*", &SearchOptions::default());
assert_eq!(
sorted(ids(&hits)),
vec![percent, underscore, contains],
"star globs, % and _ stay literal"
);
}
#[test]
fn diacritic_fts_candidates_are_dropped() {
let (_dir, p) = Scratch::db("diacritic");
let mut s = Seeder::new(&p);
// "cafe" never occurs in the accented body: not an FTS candidate, and
// exact full-text would drop it even if a diacritic-folding tokenizer
// were configured.
let _accented = s.add("menu.txt", "/d", 1, Some("le café est ouvert"));
let plain = s.add("plain.txt", "/d", 2, Some("the cafe is open"));
let conn = s.done();
let (hits, _) = run_collect(&conn, "cafe", &SearchOptions::default());
assert_eq!(ids(&hits), vec![plain]);
}
/// Without positions the index finds candidates by trigrams alone, adjacent
/// or not; the full-text pass must drop those whose text lacks the term.
#[test]
fn a_compact_index_drops_candidates_whose_trigrams_are_not_adjacent() {
let (_dir, p) = Scratch::db("scattered");
let mut s = Seeder::new(&p);
// Holds `abc`, `bcd` and `cde`, never `abcde`.
let scattered = s.add("scattered.txt", "/d", 1, Some("abcx bcdy cdez"));
let whole = s.add("whole.txt", "/d", 2, Some("the abcde run"));
let conn = s.done();
assert_eq!(
quicksearch_core::testutil::fts_rowids(&conn, "abcde").len(),
2,
"both are FTS candidates"
);
let (hits, _) = run_collect(&conn, "abcde", &SearchOptions::default());
assert_eq!(ids(&hits), vec![whole]);
assert!(!ids(&hits).contains(&scattered));
}
/// The layout changes how candidates are found, never what a search
/// returns: every query shape answers alike on either index.
#[test]
fn both_index_layouts_answer_every_query_shape_alike() {
let bodies = [
("ocean.txt", "the ocean forest at dawn"),
("reversed.txt", "a forest by the ocean"),
("report.txt", "Quarterly Report: revenue rose"),
("lower.txt", "the quarterly report is late"),
("wild.txt", "reprocessing the export"),
("typo.txt", "the mountian pass"),
("code.txt", "impl Iterator for Walker"),
("scattered.txt", "impl it; Iterator; for x"),
];
let queries: &[(&str, bool)] = &[
("ocean forest", false),
("Report", false),
("report", false),
("rep*rt", false),
("impl Iterator for", false),
("regex:revenue\\s+rose", false),
("mountain", true),
("iterator", true),
];
let answers: Vec<Vec<Vec<(i64, u8)>>> = [false, true]
.into_iter()
.map(|positions| {
let (_dir, p) = Scratch::db("layouts");
let mut s = Seeder::with_positions(&p, positions);
for (name, body) in bodies {
s.add(name, "/d", 1, Some(body));
}
let conn = s.done();
queries
.iter()
.map(|&(query, fuzzy)| {
let options = SearchOptions {
fuzzy,
..SearchOptions::default()
};
staged(&run_collect(&conn, query, &options).0)
})
.collect()
})
.collect();
for (i, (query, _)) in queries.iter().enumerate() {
assert_eq!(answers[0][i], answers[1][i], "{query}");
assert!(!answers[0][i].is_empty(), "{query} should find something");
}
}
#[test]
fn filters_apply_to_every_stage() {
let (_dir, p) = Scratch::db("filters");
let mut s = Seeder::new(&p);
let keep_name = s.add_native("alpha.txt", "/keep", 1, None);
let _skip_name = s.add_native("alpha.txt", "/skip", 1, None);
let keep_text = s.add_native("k.txt", "/keep", 2, Some("alpha body"));
let _skip_text = s.add_native("s.txt", "/skip", 2, Some("alpha body"));
let keep_fuzzy = s.add_native("alpah.txt", "/keep", 3, None);
let _skip_fuzzy = s.add_native("alpah.txt", "/skip", 3, None);
let keep_path = s.add_native("p.bin", "/keep/alpha-sub", 4, None);
let _skip_path = s.add_native("p.bin", "/skip/alpha-sub", 4, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "alpha path:/keep", &fuzzy_options());
assert_eq!(
sorted(ids(&hits)),
sorted(vec![keep_name, keep_text, keep_fuzzy, keep_path]),
"the path filter must gate all stages"
);
}
/// `limited` means "there may be more". `flush_pass` sets it when it
/// truncates, and so does a limit filled with rows or passes left: a scan
/// breaking with rows unexamined, or the cascade stopping before its later
/// passes. Those two can report "(truncated…)" over a complete set, which
/// "limit-exact" and "limit-one" pin: telling them apart costs a row scanned
/// past the limit, or the unrun passes, on every keystroke.
///
/// The break sits after classification: the filename pass's SQL is a superset
/// feeding two rank tiers, so a scanned row is not yet a match, and breaking on
/// one would lose the hits below it.
#[test]
fn the_limit_truncates_flags_and_keeps_the_best_hits() {
// (tag, `zz-match-NN.txt` files, then an exact `match`?, limit)
// => (hits, limited, first hit)
let cases = [
// 10 matches under a limit of 3 is a cut set.
(("limit", 10, false, 3), (3, true, "zz-match-00.txt")),
// Exactly `limit` results with passes left to run: those passes
// could have found more, so the set says "there may be more".
(("limit-exact", 3, false, 3), (3, true, "zz-match-00.txt")),
// The scan breaks with rows unexamined and says the same.
(("limit-one", 1, false, 1), (1, true, "zz-match-00.txt")),
// The exact match, seeded last, is scanned last too. It is kept only
// because the hits after the first, which goes out on its own, fit
// one flush buffer, ranked before the cut. Past a buffer the scan
// stops at the limit and keeps the first found (the known exception
// on `Cx::flush_pass`).
(("limit-break", 20, true, 2), (2, true, "match")),
];
for ((tag, subs, exact_last, limit), (len, limited, first)) in cases {
let (_dir, p) = Scratch::db(tag);
let mut s = Seeder::new(&p);
for i in 0..subs {
s.add(&format!("zz-match-{:02}.txt", i), "/d", 1, None);
}
if exact_last {
s.add("match", "/d", 1, None);
}
let conn = s.done();
let options = SearchOptions {
limit,
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "match", &options);
assert_eq!(hits.len(), len, "{}", tag);
assert_eq!(outcome.total, len, "{}", tag);
assert_eq!(outcome.limited, limited, "{}", tag);
assert_eq!(hits[0].name, first, "{}", tag);
}
}
/// The name pass fills the limit exactly, and the full-text pass, which
/// would have found one more, never runs: the set is cut and says so.
#[test]
fn a_full_set_with_a_body_match_left_is_limited() {
let (_dir, p) = Scratch::db("limit-body");
let mut s = Seeder::new(&p);
let names: Vec<i64> = (0..3)
.map(|i| s.add(&format!("match-{}.txt", i), "/d", 1, None))
.collect();
let _body = s.add("notes.txt", "/d", 1, Some("a match in the text"));
let conn = s.done();
let options = SearchOptions {
limit: 3,
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "match", &options);
assert_eq!(sorted(ids(&hits)), names, "the name hits fill the set");
assert_eq!(outcome.total, 3);
assert!(
outcome.limited,
"the full-text pass never ran, so there may be more"
);
}
/// One short of the limit, every pass runs: the set is complete.
#[test]
fn a_set_one_short_of_the_limit_is_complete() {
let (_dir, p) = Scratch::db("limit-short");
let mut s = Seeder::new(&p);
let name = s.add("match.txt", "/d", 1, None);
let body = s.add("notes.txt", "/d", 1, Some("a match in the text"));
let conn = s.done();
let options = SearchOptions {
limit: 3,
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "match", &options);
assert_eq!(ids(&hits), vec![name, body], "a name hit, then a body hit");
assert_eq!(outcome.total, 2);
assert!(!outcome.limited, "nothing was dropped and every pass ran");
}
/// The CLI's `--limit` has no upper bound. The deferred path tier's cap is
/// twice the room left, so past `usize::MAX / 2` it must saturate: wrapped,
/// it fell to the 4,096 floor and the cut indexed past the buffer.
#[test]
fn a_limit_past_half_of_usize_does_not_panic() {
let (_dir, p) = Scratch::db("limit-huge");
let mut s = Seeder::new(&p);
// Path-tier hits only: the parent holds the term, the name does not.
// More than the floor, so the cap is checked.
let rows = 4200;
for i in 0..rows {
s.add(&format!("f{:04}.bin", i), "/d/needle-sub", 1, None);
}
let conn = s.done();
let options = SearchOptions {
limit: usize::MAX / 2 + 1,
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "needle", &options);
assert_eq!(hits.len(), rows, "every path hit comes back");
assert_eq!(outcome.total, rows);
assert!(!outcome.limited, "nothing was dropped and every pass ran");
}
#[test]
fn session_ignores_hide_hits_before_the_cap() {
let (_dir, p) = Scratch::db("ignores");
let mut s = Seeder::new(&p);
let keep = s.add("keep-match.txt", "/d", 1, None);
let _log = s.add("match.log", "/d", 2, None);
let _sub = s.add("match.txt", "/d/logs", 3, None);
// Would be a rank-9 path hit, but its parent is an ignored component.
let _path_hit = s.add("z.bin", "/d/logs/match-sub", 4, None);
let conn = s.done();
let options = SearchOptions {
session_ignores: vec!["*.log".to_string(), "logs".to_string()],
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "match", &options);
assert_eq!(ids(&hits), vec![keep]);
assert_eq!(outcome.total, 1, "ignored rows never count toward totals");
}
#[test]
fn empty_and_filter_only_terms_return_nothing() {
let (_dir, p) = Scratch::db("empty");
let mut s = Seeder::new(&p);
s.add("anything.txt", "/d", 1, Some("anything"));
let conn = s.done();
for input in ["", " ", "type:Text"] {
let (hits, outcome) = run_collect(&conn, input, &SearchOptions::default());
assert!(hits.is_empty(), "input {:?}", input);
assert_eq!(outcome.total, 0);
}
}
#[test]
fn hostile_terms_are_inert() {
let (_dir, p) = Scratch::db("hostile");
let mut s = Seeder::new(&p);
s.add("innocent.txt", "/d", 1, Some("innocent content"));
let conn = s.done();
for term in [
"'; DROP TABLE files; --",
"\" OR 1=1 --",
// FTS metacharacters after a live wildcard must still be inert.
"term* (NEAR) : ^",
"a\0b",
"*",
"****",
"* *",
&format!("{}*", "x".repeat(10_000)),
] {
let split = split_for_cascade(term).expect("split never fails on words");
let latest = AtomicU64::new(1);
let result = cascade::run(
&conn,
&split,
&SearchOptions::default(),
1,
&latest,
&mut |_| {},
);
assert!(result.is_ok(), "term {:?}: {:?}", term, result.err());
}
let n: i64 = conn
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
.unwrap();
assert_eq!(n, 1);
}
#[test]
fn wildcard_name_ranks_through_the_same_tiers() {
let (_dir, p) = Scratch::db("wildranks");
let mut s = Seeder::new(&p);
// `report*` anchors the whole name, so these are tiers 1 and 2 …
let whole_cs = s.add("report2024.pdf", "/a", 1, None);
let whole_ci = s.add("Report2024.pdf", "/b", 2, None);
// … and a name that merely contains the pattern is tier 3/4; a trailing
// star can match nothing, so "report" mid-name counts too.
let sub_cs = s.add("my-report-final.txt", "/c", 3, None);
let sub_ci = s.add("my-Report-final.txt", "/d", 4, None);
let suffix = s.add("2024report.pdf", "/e", 5, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "report*", &SearchOptions::default());
assert_eq!(
staged(&hits),
// Within rank 3 the tie breaks by name: "2024…" sorts first.
vec![
(whole_cs, 1),
(whole_ci, 2),
(suffix, 3),
(sub_cs, 3),
(sub_ci, 4)
],
"wildcard terms rank exactly like literal ones"
);
let (ordered, _) = run_collect(&conn, "report*2024", &SearchOptions::default());
assert!(
!ordered.iter().any(|h| h.name == "2024report.pdf"),
"segments must match in order"
);
}
/// A wildcard's classifier folds Unicode case, so pass A's `LIKE` must admit
/// every case partner it accepts, in both branches: the anchor segment (path
/// tiers on) and the joined name pattern (under the trigram floor).
#[test]
fn a_wildcard_finds_unicode_case_variants_by_name() {
let (_dir, p) = Scratch::db("wildunicode");
let mut s = Seeder::new(&p);
let resume = s.add("RÉSUMÉ 2024.pdf", "/d", 1, None);
let e_acute = s.add("É.txt", "/d", 2, None);
// U+212A KELVIN SIGN, a case partner of `k`.
let kelvin = s.add("\u{212A}ILOWATT.txt", "/d", 3, None);
let report = s.add("Отчёт.docx", "/d", 4, None);
let conn = s.done();
for (query, want) in [
("résumé*", vec![(resume, 2)]),
// One character: the name-only branch. RÉSUMÉ holds an É too.
("é*", vec![(e_acute, 2), (resume, 4)]),
("kilo*", vec![(kelvin, 2)]),
("отч*", vec![(report, 2)]),
] {
let (hits, _) = run_collect(&conn, query, &SearchOptions::default());
assert_eq!(staged(&hits), want, "{query}");
}
}
#[test]
fn extension_glob_whole_matches_every_such_file() {
let (_dir, p) = Scratch::db("extglob");
let mut s = Seeder::new(&p);
let a = s.add("alpha.txt", "/d", 1, None);
let b = s.add("beta.txt", "/d", 2, None);
let upper = s.add("GAMMA.TXT", "/d", 3, None);
let _other = s.add("delta.pdf", "/d", 4, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "*.txt", &SearchOptions::default());
let mut tier1: Vec<i64> = hits
.iter()
.filter(|h| h.stage == 1)
.map(|h| h.file_id)
.collect();
tier1.sort();
assert_eq!(tier1, vec![a, b], "every exact-case .txt is a tier-1 hit");
assert_eq!(
hits.iter()
.filter(|h| h.stage == 2)
.map(|h| h.file_id)
.collect::<Vec<_>>(),
vec![upper],
"case-folded whole match lands at tier 2"
);
assert!(!hits.iter().any(|h| h.name == "delta.pdf"));
}
#[test]
fn wildcard_fulltext_narrows_with_fts_and_verifies_order() {
let (_dir, p) = Scratch::db("wildtext");
let mut s = Seeder::new(&p);
let ordered = s.add("a.txt", "/d", 1, Some("a wondrous world indeed"));
// FTS AND-of-segments finds this too (both trigram runs occur), but the
// pattern requires "wond" before "world", so verification drops it.
let _reversed = s.add("b.txt", "/d", 2, Some("world of wonders"));
let conn = s.done();
let (hits, _) = run_collect(&conn, "wond*world", &SearchOptions::default());
assert_eq!(
staged(&hits),
vec![(ordered, 5)],
"unordered FTS candidates must fail pattern verification"
);
let snip = hits[0]
.snippet
.as_ref()
.expect("wildcard hit has a snippet");
assert_eq!(snip.ranges.len(), 1);
let (a, b) = snip.ranges[0];
assert_eq!(&snip.window[a..b], "wondrous world");
}
#[test]
fn wildcard_with_short_segments_falls_back_to_a_full_scan() {
let (_dir, p) = Scratch::db("wildshort");
let mut s = Seeder::new(&p);
let hit = s.add("doc.txt", "/d", 1, Some("zz abXcd zz"));
let _miss = s.add("other.txt", "/d", 2, Some("cd before ab"));
let conn = s.done();
let (hits, _) = run_collect(&conn, "ab*cd", &SearchOptions::default());
assert_eq!(staged(&hits), vec![(hit, 5)]);
}
/// The `LIKE` prefilter a straddling wildcard gets is a superset of what the
/// classifier accepts, or real hits would vanish silently. Seeds rows
/// exercising every way a segment can sit across `parent || name`, and checks
/// the prefiltered pass against a reference set computed from the compiled
/// pattern.
#[test]
fn a_wildcard_prefilter_never_loses_a_hit_the_full_scan_finds() {
let (_dir, p) = Scratch::db("wildprefilter");
let mut s = Seeder::new(&p);
let in_name = s.add("report-q3.txt", "/data", 1, None);
let in_parent = s.add("a.bin", "/reports/q3", 2, None);
// The pattern's `%` spans the parent/name boundary.
let across = s.add("q3.txt", "/reports", 3, None);
let folded = s.add("REPORT-Q3.TXT", "/upper", 4, None);
let miss = s.add("summary.txt", "/data", 5, None);
// The trap the separator-free rule exists for: the only way to read this
// row as a match spans the boundary, and the spanning text contains the
// separator itself.
let boundary = s.add("q3.txt", "/x/rep", 6, None);
// Makes the separator-free rule load-bearing: against `e/pq*txt` the
// longest segment `e/pq` occurs in `parent || name` only across the
// join, so anchoring on it would drop this row; the rule falls to `txt`.
let straddling = s.add("pq.txt", "/a/re", 7, None);
let conn = s.done();
let opts = SearchOptions::default();
for query in ["rep*q3", "rep*rt", "*report*", "re*or*q3", "e/pq*txt"] {
let (hits, _) = run_collect(&conn, query, &opts);
let got = sorted(ids(&hits));
// The reference set is computed directly: a row is expected exactly
// when the compiled pattern matches its name or its full path.
let split = split_for_cascade(query).unwrap();
let mut want: Vec<i64> = [
(in_name, "/data/report-q3.txt"),
(in_parent, "/reports/q3/a.bin"),
(across, "/reports/q3.txt"),
(folded, "/upper/REPORT-Q3.TXT"),
(miss, "/data/summary.txt"),
(boundary, "/x/rep/q3.txt"),
(straddling, "/a/re/pq.txt"),
]
.iter()
.filter(|(_, path)| {
let name = path.rsplit('/').next().unwrap();
split.pattern.find_first(name, true).is_some()
|| split.pattern.find_first(path, true).is_some()
})
.map(|(id, _)| *id)
.collect();
want.sort();
assert_eq!(got, want, "query {:?}", query);
}
}
/// The fuzzy filename pass's pigeonhole `LIKE` prefilter is a superset of what
/// the bitap accepts over name and path, or fuzzy hits would vanish silently.
/// The reference set is computed with the same matcher the pass uses, so the
/// assertion is against the semantics, not a copy of the SQL.
/// `fuzzy_max_edits: 1` keeps `k` at 1, which "report" is long enough to split
/// for; at the default 2 the term is too short and the pass would scan,
/// exercising nothing.
#[test]
fn a_fuzzy_prefilter_never_loses_a_hit_the_full_scan_finds() {
use quicksearch_core::search::fuzzy::{edit_budget, Bitap};
let (_dir, p) = Scratch::db("fuzzyprefilter");
let mut s = Seeder::new(&p);
let rows = [
("report.txt", "/data"), // exact
("REPORT.TXT", "/upper"), // ASCII case is free for LIKE and bitap both
("xeport.txt", "/data"), // first chunk broken; the OR reaches the second
("repXrt.txt", "/data"), // second chunk broken; the first carries it
("ort.txt", "/rep"), // name over budget, path-tier hit across the join
("summary.txt", "/data"), // miss
("rep\u{f6}rt.txt", "/data"), // non-ASCII: two byte edits, over a k of 1
];
let seeded: Vec<(i64, String)> = rows
.iter()
.enumerate()
.map(|(i, (name, dir))| {
let id = s.add(name, dir, i as u64 + 1, None);
(id, format!("{}/{}", dir, name))
})
.collect();
let conn = s.done();
let opts = SearchOptions {
fuzzy: true,
fuzzy_max_edits: 1,
..SearchOptions::default()
};
let term = "report";
let (hits, _) = run_collect(&conn, term, &opts);
let got = sorted(ids(&hits));
let k = edit_budget(term.len(), opts.fuzzy_max_edits).unwrap();
let bitap = Bitap::new(term.as_bytes(), k).unwrap();
let mut want: Vec<i64> = seeded
.iter()
.filter(|(_, path)| {
let name = path.rsplit('/').next().unwrap();
bitap.best_distance_and_first(name.as_bytes()).is_some()
|| bitap.best_distance_and_first(path.as_bytes()).is_some()
})
.map(|(id, _)| *id)
.collect();
want.sort();
assert_eq!(got, want);
assert!(
(5..seeded.len()).contains(&got.len()),
"the corpus must exercise both hits and misses: {:?}",
got
);
}
/// A term carrying a separator disqualifies every chunk set
/// (`prefilter::Required::like_predicate`), so the fuzzy pass falls back to
/// the full scan, and still finds a hit whose only match spans the
/// `parent‖name` join.
#[test]
fn a_fuzzy_term_with_a_separator_still_scans_and_matches() {
let (_dir, p) = Scratch::db("fuzzysep");
let mut s = Seeder::new(&p);
let hit = s.add("orts.txt", "/a/rep", 1, None);
let _miss = s.add("plans.txt", "/a/sum", 2, None);
let conn = s.done();
let opts = SearchOptions {
fuzzy: true,
fuzzy_max_edits: 1,
..SearchOptions::default()
};
// Within one edit of "/a/rep/orts" read across the join.
let (hits, _) = run_collect(&conn, "rep/orts", &opts);
assert_eq!(ids(&hits), vec![hit]);
}
/// A pattern every segment of which carries a separator has nothing to anchor
/// on, so the pass falls back to scanning, and still finds its hits.
#[test]
fn a_wildcard_with_only_separator_segments_still_scans_and_matches() {
let (_dir, p) = Scratch::db("wildnoanchor");
let mut s = Seeder::new(&p);
let hit = s.add("q3.txt", "/a/reports", 1, None);
let _miss = s.add("q3.txt", "/a/summaries", 2, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "a/rep*rts/", &SearchOptions::default());
assert_eq!(ids(&hits), vec![hit]);
}
#[test]
fn wildcard_path_tier_and_filters() {
let (_dir, p) = Scratch::db("wildpath");
let mut s = Seeder::new(&p);
let dir_hit = s.add_native("a.bin", "/Vacation-2024", 1, None);
let filtered = s.add_native("b.bin", "/elsewhere/Vacation-2023", 2, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "Vac*tion", &SearchOptions::default());
assert_eq!(sorted(ids(&hits)), sorted(vec![dir_hit, filtered]));
assert!(hits.iter().all(|h| h.stage == 9), "term only in the path");
let (kept, _) = run_collect(&conn, "Vac*tion path:/elsewhere", &SearchOptions::default());
assert_eq!(ids(&kept), vec![filtered]);
}
#[test]
fn wildcard_terms_skip_the_fuzzy_stages() {
let (_dir, p) = Scratch::db("wildfuzzy");
let mut s = Seeder::new(&p);
let real = s.add("report.txt", "/d", 1, None);
// A 2-edit typo: fuzzy would admit it for a literal term.
let _typo = s.add("Reprot.txt", "/d", 2, None);
let _typo_body = s.add("body.txt", "/d", 3, Some("the reoprt went missing"));
let conn = s.done();
let (hits, _) = run_collect(&conn, "rep*rt", &fuzzy_options());
assert_eq!(
ids(&hits),
vec![real],
"no stage 7/8/11 hits for a wildcard term even with fuzzy on"
);
}
#[test]
fn regex_only_query_hits_name_content_and_path() {
let (_dir, p) = Scratch::db("regexonly");
let mut s = Seeder::new(&p);
let by_name = s.add("qz42.txt", "/d", 1, None);
let by_content = s.add("notes.txt", "/d", 2, Some("ref qz7 in the body"));
let by_path = s.add("b.bin", "/qz99-dir", 3, None);
let _miss = s.add("plain.txt", "/d", 4, Some("nothing here"));
let conn = s.done();
let (hits, _) = run_collect(&conn, r"regex:qz\d+", &SearchOptions::default());
assert_eq!(
staged(&hits),
vec![(by_name, 4), (by_content, 6), (by_path, 10)],
"regex-only reuses the name/content/path tiers in cascade order"
);
let name_snip = hits[0].snippet.as_ref().unwrap();
let (a, b) = name_snip.ranges[0];
assert_eq!(&name_snip.window[a..b], "qz42");
let path_snip = hits[2].snippet.as_ref().unwrap();
let (a, b) = path_snip.ranges[0];
assert_eq!(&path_snip.window[a..b], "qz99");
let body_snip = hits[1].snippet.as_ref().unwrap();
let (a, b) = body_snip.ranges[0];
assert_eq!(&body_snip.window[a..b], "qz7");
}
#[test]
fn regex_is_case_insensitive_by_default_and_respects_filters() {
let (_dir, p) = Scratch::db("regexci");
let mut s = Seeder::new(&p);
let keep = s.add_native("QZ1.txt", "/keep", 1, None);
let skip = s.add_native("qz2.txt", "/skip", 2, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, r"regex:qz\d path:/keep", &SearchOptions::default());
assert_eq!(ids(&hits), vec![keep]);
// Unquoted: the parens and the colon belong to the pattern.
let (cs, _) = run_collect(&conn, r"regex:(?-i:qz)\d", &SearchOptions::default());
assert_eq!(ids(&cs), vec![skip]);
}
/// Operators in plain text are searched as typed, spaces included.
#[test]
fn operator_text_is_searched_as_typed() {
let (_dir, p) = Scratch::db("optext");
let mut s = Seeder::new(&p);
let code = s.add("main.cpp", "/src", 1, Some("using std::string here"));
let _glued = s.add("other.cpp", "/src", 2, Some("std:string is a typo"));
let mail = s.add("Re: meeting.eml", "/mail", 3, None);
let _joined = s.add("Re:meeting.eml", "/mail", 4, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "std::string", &SearchOptions::default());
assert_eq!(staged(&hits), vec![(code, 5)]);
let (hits, _) = run_collect(&conn, "Re: meeting", &SearchOptions::default());
assert_eq!(staged(&hits), vec![(mail, 3)]);
}
/// `regex:` folds Unicode case, so the name pass's `LIKE` must admit every
/// case partner of its literals, in names (rank 4) and in paths (rank 10).
#[test]
fn regex_name_pass_finds_unicode_case_variants() {
let (_dir, p) = Scratch::db("regexunicode");
let mut s = Seeder::new(&p);
let cafe = (
s.add("CAFÉ.txt", "/d", 1, None),
s.add("x.bin", "/CAFÉ", 2, None),
);
let kilo = (
s.add("\u{212A}ILO.txt", "/d", 3, None),
s.add("y.bin", "/\u{212A}ILO", 4, None),
);
let report = (
s.add("ОТЧЁТ.txt", "/d", 5, None),
s.add("z.bin", "/ОТЧЁТ", 6, None),
);
let conn = s.done();
for (query, (by_name, by_path)) in [
("regex:café", cafe),
("regex:kilo", kilo),
("regex:отчёт", report),
] {
let (hits, _) = run_collect(&conn, query, &SearchOptions::default());
assert_eq!(staged(&hits), vec![(by_name, 4), (by_path, 10)], "{query}");
}
}
/// Pins the accepted limit in `search/prefilter.rs`: FTS5 folds case with
/// SQLite's own, older table, so a body holding only a case partner added
/// since (Georgian Mtavruli capitals, Unicode 11) is no FTS candidate for a
/// `*` pattern or `regex:`, though both matchers accept it. A failure here
/// means SQLite's table caught up and the documented limit can go. The
/// Cyrillic body is the control, inside both tables.
#[test]
fn content_matching_folds_case_only_as_far_as_the_fts_table() {
const MTAVRULI: &str = "ᲥᲐᲠᲗᲣᲚᲘ";
let (_dir, p) = Scratch::db("ftsfold");
let mut s = Seeder::new(&p);
let georgian = s.add("a.txt", "/d", 1, Some(&format!("{MTAVRULI} ენა")));
let cyrillic = s.add("b.txt", "/d", 2, Some("ОТЧЁТ за год"));
let conn = s.done();
for query in ["ქართ*ული", "regex:ქართ"] {
let split = split_for_cascade(query).unwrap();
let accepted = match &split.regex {
Some(re) => re.is_match(MTAVRULI),
None => split.pattern.find_first(MTAVRULI, true).is_some(),
};
assert!(accepted, "{query}: the matcher folds the pair");
let (hits, _) = run_collect(&conn, query, &SearchOptions::default());
assert!(
!ids(&hits).contains(&georgian),
"{query}: {:?}",
staged(&hits)
);
}
let (hits, _) = run_collect(&conn, "regex:отчёт", &SearchOptions::default());
assert_eq!(staged(&hits), vec![(cyrillic, 6)]);
}
#[test]
fn regex_alongside_a_term_is_an_accept_predicate() {
let (_dir, p) = Scratch::db("regexpred");
let mut s = Seeder::new(&p);
// Kept via the lazy content fetch: the regex is nowhere in its name/path.
let kept = s.add("budget-a.txt", "/d", 1, Some("code acme7 inside"));
let _dropped = s.add("budget-b.txt", "/d", 2, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, r"budget regex:acme\d", &SearchOptions::default());
assert_eq!(
staged(&hits),
vec![(kept, 3)],
"the term drives ranking; the regex gates acceptance"
);
}
#[test]
fn hostile_regexes_complete_quickly() {
let (_dir, p) = Scratch::db("regexhostile");
let mut s = Seeder::new(&p);
s.add("aaa.txt", "/d", 1, Some(&"a".repeat(50_000)));
let conn = s.done();
let start = std::time::Instant::now();
let (hits, _) = run_collect(&conn, r#"regex:"(a+)+$""#, &SearchOptions::default());
assert!(!hits.is_empty(), "the all-a body does end in a run of a's");
assert!(
start.elapsed() < std::time::Duration::from_secs(5),
"hostile regex must not blow up: took {:?}",
start.elapsed()
);
}
#[test]
fn service_surfaces_invalid_regex_as_an_error() {
let (_dir, p) = Scratch::db("regexerr");
let mut s = Seeder::new(&p);
s.add("anything.txt", "/d", 1, None);
drop(s.done());
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
let message = search_names(&service, &updates, "regex:[")
.expect_err("invalid regex must surface as a search error");
assert!(message.contains("regex"), "unhelpful message: {}", message);
service.shutdown();
}
#[test]
fn generation_bump_cancels_mid_stream() {
let (_dir, p) = Scratch::db("cancel");
let mut s = Seeder::new(&p);
for i in 0..500 {
s.add(&format!("bulk-{:04}.txt", i), "/d", 1, None);
}
let conn = s.done();
let split = split_for_cascade("bulk").unwrap();
let latest = Arc::new(AtomicU64::new(3));
let latest_for_sink = latest.clone();
let mut batches = 0usize;
let outcome = cascade::run(
&conn,
&split,
&SearchOptions::default(),
3,
&latest,
&mut |_batch| {
batches += 1;
// Simulate a new keystroke arriving after the first batch.
latest_for_sink.store(99, Ordering::SeqCst);
},
)
.unwrap();
assert!(outcome.is_none(), "cancelled search must not complete");
assert_eq!(batches, 1, "no further batches after the generation moved");
}
#[test]
fn service_rapid_fire_completes_only_the_last_generation() {
let (_dir, p) = Scratch::db("service");
let mut s = Seeder::new(&p);
for i in 0..2000 {
s.add(
&format!("file-{:04}.txt", i),
"/d",
1,
Some("shared corpus body text"),
);
}
drop(s.done());
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
for i in 0..50 {
service.search(&format!("corpus body {}", i % 3), SearchOptions::default());
}
// Final query that actually matches, so completion carries hits too.
let last_gen = service.search("corpus", SearchOptions::default());
let mut completed: Vec<u64> = Vec::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while std::time::Instant::now() < deadline {
match updates.recv_timeout(std::time::Duration::from_millis(200)) {
Ok(SearchUpdate::Completed { generation, .. }) => {
completed.push(generation);
if generation == last_gen {
break;
}
}
Ok(_) => {}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
assert_eq!(
completed.last().copied(),
Some(last_gen),
"the newest generation must be the one that completes (saw {:?})",
completed
);
service.shutdown();
}
#[test]
fn service_reports_missing_db_as_error() {
let (_dir, missing) = Scratch::db("missing");
let (service, updates) = SearchService::new(missing.clone(), Arc::new(|| {}));
search_names(&service, &updates, "anything")
.expect_err("missing index must surface as a search error");
service.shutdown();
}
/// Collect the names one search returns, or the error it produced. The
/// worker keeps its connection between requests, so these tests run several
/// searches against one service — the situation the reuse has to survive.
fn search_names(
service: &SearchService,
updates: &std::sync::mpsc::Receiver<SearchUpdate>,
query: &str,
) -> Result<Vec<String>, String> {
let generation = service.search(query, SearchOptions::default());
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
let mut names = Vec::new();
while std::time::Instant::now() < deadline {
match updates.recv_timeout(std::time::Duration::from_millis(200)) {
Ok(SearchUpdate::Hits {
generation: g,
hits,
}) if g == generation => {
names.extend(hits.into_iter().map(|h| h.name));
}
Ok(SearchUpdate::Completed { generation: g, .. }) if g == generation => {
names.sort();
return Ok(names);
}
Ok(SearchUpdate::Error {
generation: g,
message,
}) if g == generation => return Err(message),
Ok(_) => {}
// A quiet stretch is a loaded machine, not a lost search: only
// the deadline gives up.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
panic!("search {:?} never completed", query);
}
#[test]
fn repeated_searches_on_one_service_stay_correct() {
let (_dir, p) = Scratch::db("warmrepeat");
let mut s = Seeder::new(&p);
s.add("alpha.txt", "/d", 1, Some("alpha body"));
s.add("beta.txt", "/d", 1, Some("beta body"));
drop(s.done());
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
for _ in 0..3 {
assert_eq!(
search_names(&service, &updates, "alpha").unwrap(),
vec!["alpha.txt"]
);
assert_eq!(
search_names(&service, &updates, "beta").unwrap(),
vec!["beta.txt"]
);
}
service.shutdown();
}
/// A rebuild replaces the file at the same path, so nothing about the path
/// tells a held connection it now looks at a deleted inode; `db::index_epoch`
/// does.
#[test]
fn a_rebuilt_index_at_the_same_path_is_picked_up() {
let (_dir, p) = Scratch::db("warmrebuild");
let mut s = Seeder::new(&p);
s.add("before.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["before.txt"],
"the pre-rebuild index answers first"
);
// A rebuild, spelled the way the coordinator spells one: the wipe branch
// of `open_or_recreate` is the real call site that bumps the epoch, so
// this exercises the actual invalidation rather than poking the counter.
for suffix in ["", "-wal", "-shm"] {
std::fs::remove_file(format!("{}{}", p.display(), suffix)).ok();
}
let mut s = Seeder::new(&p);
s.add("after.txt", "/d", 1, Some("shared body"));
drop(s.done());
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["after.txt"],
"a held connection served the replaced index"
);
service.shutdown();
}
#[test]
fn set_db_path_repoints_a_held_connection() {
let (_dir1, first) = Scratch::db("warmpath1");
let mut s = Seeder::new(&first);
s.add("infirst.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (_dir2, second) = Scratch::db("warmpath2");
let mut s = Seeder::new(&second);
s.add("insecond.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (service, updates) = SearchService::new(first.clone(), Arc::new(|| {}));
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["infirst.txt"]
);
service.set_db_path(second.clone());
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["insecond.txt"],
"the connection stayed on the old index after the path moved"
);
service.shutdown();
}
#[test]
fn a_failed_search_does_not_wedge_the_next_one() {
let (_dir, p) = Scratch::db("warmrecover");
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
search_names(&service, &updates, "anything").expect_err("no index yet");
let mut s = Seeder::new(&p);
s.add("arrived.txt", "/d", 1, Some("shared body"));
drop(s.done());
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["arrived.txt"],
"the failure left the worker unable to open the index that appeared"
);
service.shutdown();
}
/// Whether this process holds a descriptor on `p`, read from
/// `/proc/self/fd`.
#[cfg(target_os = "linux")]
fn holds_fd_on(p: &Path) -> bool {
let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
return false;
};
entries
.filter_map(|e| e.ok())
.filter_map(|e| std::fs::read_link(e.path()).ok())
.any(|target| target == p)
}
/// The connection is released once a search session goes quiet: a deleted
/// index keeps its blocks until the last handle closes, and an open reader
/// stops SQLite from resetting the WAL. Checked through `/proc/self/fd`;
/// a reopen-still-works assertion would pass either way.
#[cfg(target_os = "linux")]
#[test]
fn the_connection_is_released_once_searching_stops() {
let (_dir, p) = Scratch::db("warmrelease");
let mut s = Seeder::new(&p);
s.add("held.txt", "/d", 1, Some("shared body"));
drop(s.done());
let holds_index = || holds_fd_on(&p);
let (service, updates) = SearchService::new_with_idle_release(
p.clone(),
Arc::new(|| {}),
std::time::Duration::from_millis(150),
);
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["held.txt"]
);
assert!(
holds_index(),
"the connection should be held across requests"
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while holds_index() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(50));
}
assert!(!holds_index(), "the idle connection was never released");
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["held.txt"]
);
service.shutdown();
}
/// A removal has the search worker let go before it deletes: on Windows a
/// held connection fails the delete, and the worker keeps one for
/// `IDLE_RELEASE`. Observed from a holder registered after the service's,
/// which runs after it and before the delete.
#[cfg(target_os = "linux")]
#[test]
fn a_removal_releases_the_search_connection_first() {
let (_dir, p) = Scratch::db("relremove");
let mut s = Seeder::new(&p);
s.add("held.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["held.txt"]
);
assert!(
holds_fd_on(&p),
"the connection should be held across requests"
);
let held_at_delete = Arc::new(std::sync::Mutex::new(None));
let observer = {
let (held, target) = (held_at_delete.clone(), p.clone());
quicksearch_core::db::hold_index(move |path| {
if path == target {
*held.lock().unwrap() = Some(holds_fd_on(path));
}
})
};
quicksearch_core::db::remove_index_files(&p).unwrap();
drop(observer);
assert_eq!(
*held_at_delete.lock().unwrap(),
Some(false),
"the search connection was still open when the delete began"
);
service.shutdown();
}
/// The removal's own window: a search that reopens the index after the
/// worker let go and before the unlink would serve the deleted file for as
/// long as the connection lives, unless the epoch moves after the delete.
/// The search is run from a holder registered after the service's, so it
/// lands exactly there. Unix only: Windows fails the delete instead.
#[cfg(unix)]
#[test]
fn a_reader_reopened_inside_the_removal_window_is_dropped() {
let (_dir, p) = Scratch::db("relwindow");
let mut s = Seeder::new(&p);
s.add("before.txt", "/d", 1, Some("shared body"));
drop(s.done());
// Built apart and closed, so the file is whole without its WAL.
let (_dir2, replacement) = Scratch::db("relwindow2");
let mut s = Seeder::new(&replacement);
s.add("after.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {}));
let (service, updates) = (Arc::new(service), Arc::new(std::sync::Mutex::new(updates)));
let names = |service: &SearchService, updates: &std::sync::Mutex<_>| {
search_names(service, &updates.lock().unwrap(), "shared").unwrap()
};
assert_eq!(names(&service, &updates), vec!["before.txt"]);
let reopen = {
let (service, updates, target) = (service.clone(), updates.clone(), p.clone());
quicksearch_core::db::hold_index(move |path| {
if path == target {
assert_eq!(names(&service, &updates), vec!["before.txt"]);
}
})
};
quicksearch_core::db::remove_index_files(&p).unwrap();
drop(reopen);
// Not through a removal, so nothing bumps the epoch from here on.
std::fs::copy(&replacement, &p).unwrap();
assert_eq!(
names(&service, &updates),
vec!["after.txt"],
"a reader reopened during the removal kept serving the deleted index"
);
// Dropped, not shut down: a removal elsewhere in this binary may hold the
// withdrawn hook, and its clone of the service, a moment longer.
}
/// An explicit release must leave the file closed even when a search was
/// queued a moment before it: `release_connection` bumps the generation
/// first, so the queued request is stale and the worker drops it instead of
/// reopening the index while the caller is on its way to delete it.
///
/// The generation is checked rather than the descriptor because the race is
/// won or lost in microseconds; an fd assertion would pass with the bug in
/// place most of the time.
#[test]
fn a_release_supersedes_a_search_queued_before_it() {
let (_dir, p) = Scratch::db("relcancel");
let mut s = Seeder::new(&p);
s.add("held.txt", "/d", 1, Some("shared body"));
drop(s.done());
let (service, updates) = SearchService::new_with_idle_release(
p.clone(),
Arc::new(|| {}),
std::time::Duration::from_secs(60),
);
let queued = service.search("shared", SearchOptions::default());
service.release_connection();
let after = service.search("shared", SearchOptions::default());
assert!(
after > queued + 1,
"the release must advance the generation past {}, got {}",
queued,
after
);
assert_eq!(
search_names(&service, &updates, "shared").unwrap(),
vec!["held.txt"]
);
service.shutdown();
}
/// A pass hands hits over while it scans. Proven by ordering, not batch count:
/// `flush_pass` chunks its output either way, so counting sink calls proves
/// nothing. Pass A scans in stored `(parent, name)` order, so a worse match
/// seeded early must arrive before a better one at a late path; emitting at
/// the end would sort them and lead with rank 1.
#[test]
fn a_pass_hands_hits_over_before_the_scan_reaches_the_end() {
let (_dir, p) = Scratch::db("stream");
let mut s = Seeder::new(&p);
let early_worse = s.add("my_zebra_file.txt", "/aaa", 1, None); // rank 3
for i in 0..400 {
s.add(&format!("filler{:04}.txt", i), "/mmm", i as u64 + 2, None);
}
let content = s.add("unrelated.txt", "/nnn", 998, Some("a zebra in the text")); // rank 5
let late_better = s.add("zebra", "/zzz", 999, None); // rank 1
let conn = s.done();
let options = SearchOptions {
batch: 100,
..SearchOptions::default()
};
let (batches, outcome) = run_collect_batches(&conn, "zebra", &options);
assert_eq!(outcome.total, 3, "every match still reaches the sink");
assert_eq!(
batches
.first()
.map(|b| b.as_slice())
.and_then(|b| b.first())
.map(|h| h.file_id),
Some(early_worse),
"the early hit should have gone out before the scan found the better one; \
batch sizes: {:?}",
batches.iter().map(|b| b.len()).collect::<Vec<_>>()
);
// ...and sorting the stream the way the GUI does still puts rank 1 on
// top: streaming must not change *what* a search returns, only when.
for batch in [1usize, 2, 100] {
let options = SearchOptions {
batch,
..SearchOptions::default()
};
let (hits, outcome) = run_collect(&conn, "zebra", &options);
assert_eq!(
ids(&hits),
vec![late_better, early_worse, content],
"batch size {} must not change the set or its ranking",
batch
);
assert_eq!(outcome.total, 3, "batch size {}", batch);
}
}
/// The time bound, which a size-only rule would miss: a query matching a
/// handful of rows out of many still paints them as the scan reaches them
/// rather than at the end.
#[test]
fn a_sparse_match_still_streams_before_the_scan_ends() {
let (_dir, p) = Scratch::db("sparse");
let mut s = Seeder::new(&p);
for i in 0..3000 {
let name = if i % 1000 == 500 {
format!("needle{:04}.txt", i)
} else {
format!("hay{:04}.txt", i)
};
s.add(&name, "/d", i as u64 + 1, Some("body"));
}
let conn = s.done();
let options = SearchOptions {
batch: 100,
..SearchOptions::default()
};
let (batches, outcome) = run_collect_batches(&conn, "needle", &options);
assert_eq!(outcome.total, 3, "all three needles found");
assert_eq!(batches.iter().map(|b| b.len()).sum::<usize>(), 3);
assert!(
!batches.is_empty() && batches[0].len() < 3,
"the first needle should not have waited for the other two; batches: {:?}",
batches.iter().map(|b| b.len()).collect::<Vec<_>>()
);
}
// ---------------------------------------------------------------------------
// Fuzzy snippets. Bitap reports only where a match ends, and the mark's
// window, start and extent are derived from that end; one test pins each.
// ---------------------------------------------------------------------------
/// The window of a fuzzy name/path hit is the whole field, never a suffix of
/// it, so the ranges index the field the frontend paints.
#[test]
fn fuzzy_name_and_path_snippets_carry_the_whole_field() {
let (_dir, p) = Scratch::db("fuzzy-whole-field");
let mut s = Seeder::new(&p);
// The match sits in the last third of the name, where a windowed snippet
// would start past zero.
s.add(
"a_long_and_deliberately_padded_out_quarterly_repot.txt",
"/home/me/documents/archive",
1,
None,
);
let conn = s.done();
let (hits, _) = run_collect(&conn, "report", &fuzzy_options());
let hit = hits
.iter()
.find(|h| h.stage == 7)
.expect("a fuzzy filename hit");
let snip = hit.snippet.as_ref().expect("a name hit carries a snippet");
assert_eq!(snip.window, hit.name, "the window is not the whole name");
assert!(!snip.truncated_start, "the name was windowed");
assert!(!snip.truncated_end, "the name was windowed");
for &(a, b) in &snip.ranges {
assert!(b <= hit.name.len(), "range {a}..{b} runs past the name");
assert!(
hit.name.is_char_boundary(a) && hit.name.is_char_boundary(b),
"range {a}..{b} is not on char boundaries"
);
}
}
/// The mark covers the matched text and nothing else: `repot` marks `Repo`
/// inside `1Reporter`, not the digit before it.
#[test]
fn a_fuzzy_mark_covers_the_matched_text_and_nothing_else() {
let (_dir, p) = Scratch::db("fuzzy-mark-span");
let mut s = Seeder::new(&p);
// A start taken as `end - term.len()` would reach back over the leading
// digit. In the body too, for the full-text tier.
s.add("1Reporter.txt", "/home/me/docs", 1, None);
s.add(
"body.txt",
"/home/me/docs",
2,
Some("filed under 1Reporter last week"),
);
let conn = s.done();
let (hits, _) = run_collect(&conn, "repot", &fuzzy_options());
// Stage 7, fuzzy filename: marked inside the whole name.
let name_hit = hits
.iter()
.find(|h| h.stage == 7)
.expect("a fuzzy filename hit");
let snip = name_hit.snippet.as_ref().expect("name tiers carry one");
let (a, b) = snip.ranges[0];
assert_eq!(
&snip.window[a..b],
"Repo",
"the mark is {:?}; it must cover the match and not the leading digit",
&snip.window[a..b]
);
// Stage 8, fuzzy full text: marked inside the snippet window.
let body_hit = hits
.iter()
.find(|h| h.stage == 8)
.expect("a fuzzy full-text hit");
let snip = body_hit.snippet.as_ref().expect("content tiers carry one");
let (a, b) = snip.ranges[0];
assert_eq!(
&snip.window[a..b],
"Repo",
"the mark is {:?}; it must cover the match and not the leading digit",
&snip.window[a..b]
);
}
/// The mark extends to the best alignment rather than stopping at the first
/// accept: `quarterly` over `quartrly` lights the whole word (one edit), not
/// the two-edit `quartrl` prefix.
#[test]
fn a_fuzzy_mark_is_not_truncated_to_a_leading_part_of_the_term() {
let (_dir, p) = Scratch::db("fuzzy-mark-full");
let mut s = Seeder::new(&p);
// The name must not match at all: a row the filename tier claims never
// reaches the full-text tier. And the body must hold a fuzzy variant: the
// term verbatim would be an exact content match, stage 5 or 6.
s.add(
"notes.txt",
"/home/me/docs",
1,
Some("the quartrly budget was revised"),
);
let conn = s.done();
let (hits, _) = run_collect(&conn, "quarterly", &fuzzy_options());
let hit = hits
.iter()
.find(|h| h.stage == 8)
.expect("a fuzzy full-text hit");
let snip = hit.snippet.as_ref().expect("content tiers carry one");
let (a, b) = snip.ranges[0];
assert_eq!(
&snip.window[a..b],
"quartrly",
"the mark is {:?}, a leading part of what matched",
&snip.window[a..b]
);
}
/// `files` stores `parent` and `name` with no concatenation, so a one-piece
/// term uses `name LIKE ? OR parent LIKE ?`, which is equivalent because a
/// separator-free term cannot span the boundary. A multi-segment wildcard can
/// span it and neither half fires; those fall back to scanning, and this
/// fixture fails if that fallback is ever optimised away.
#[test]
fn a_wildcard_spanning_the_directory_boundary_is_still_found() {
let (_dir, p) = Scratch::db("pathstraddle");
let mut s = Seeder::new(&p);
let straddling = s.add("q3.txt", "/x/docs", 1, None);
// Same two pieces, both inside the name: found either way, so it proves
// the query ran rather than that the fallback was reached.
let in_name = s.add("doc-q3.txt", "/other", 2, None);
let _miss = s.add("q3.txt", "/x/plans", 3, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "doc*q3", &SearchOptions::default());
assert_eq!(
sorted(ids(&hits)),
sorted(vec![straddling, in_name]),
"the straddling path match must not be dropped"
);
let hit = hits.iter().find(|h| h.file_id == straddling).unwrap();
assert_eq!(hit.stage, 9);
assert_eq!(hit.path, "/x/docs/q3.txt", "parent and name rejoined");
}
/// `folder:` must reach the folder's own files, not just its
/// subdirectories': the single `parent LIKE 'dir/%'` covers both only
/// because every stored parent ends in a separator.
#[test]
fn the_folder_filter_covers_the_folder_itself_and_its_subtree() {
let (_dir, p) = Scratch::db("folderself");
let mut s = Seeder::new(&p);
let own = s.add_native("top.txt", "/srv/data", 1, None);
let nested = s.add_native("deep.txt", "/srv/data/2024", 2, None);
let _sibling = s.add_native("other.txt", "/srv/data-archive", 3, None);
let _outside = s.add_native("far.txt", "/srv", 4, None);
let conn = s.done();
let (hits, _) = run_collect(&conn, "txt folder:/srv/data", &SearchOptions::default());
assert_eq!(
sorted(ids(&hits)),
sorted(vec![own, nested]),
"the folder's own files count as inside it"
);
}