-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: OWL-1.0 or later local testimony = require("testimony") local std = require("std") local mneme = require("mneme") local core = require("mneme.core") local testify = testimony.new("== mneme: ft indexing ==") local tmp_path = function() return "/tmp/mneme_ft_idx_" .. std.nanoid() .. ".mneme" end testify:that("put and get round-trip a document", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") local ok, err = ft:put("doc1", "the quick brown fox jumps") testimony.assert_not_nil(ok, err) local text = ft:get("doc1") testimony.assert_equal("the quick brown fox jumps", text) db:close() std.fs.remove(path) end) testify:that("exists returns true for indexed doc", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") testimony.assert_true(not ft:exists("doc1")) ft:put("doc1", "hello world") testimony.assert_true(ft:exists("doc1")) db:close() std.fs.remove(path) end) testify:that("del removes document and index entries", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("doc1", "hello world test") ft:put("doc2", "hello again test") local stats = ft:stats() testimony.assert_equal(2, stats.doc_count) local ok, err = ft:del("doc1") testimony.assert_not_nil(ok, err) testimony.assert_true(not ft:exists("doc1")) testimony.assert_true(ft:exists("doc2")) stats = ft:stats() testimony.assert_equal(1, stats.doc_count) db:close() std.fs.remove(path) end) testify:that("put updates existing document", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("doc1", "original content here") testimony.assert_equal("original content here", ft:get("doc1")) ft:put("doc1", "updated content now") testimony.assert_equal("updated content now", ft:get("doc1")) -- Doc count should still be 1 local stats = ft:stats() testimony.assert_equal(1, stats.doc_count) db:close() std.fs.remove(path) end) testify:that("stats reports correct corpus statistics", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("doc1", "hello world") ft:put("doc2", "hello there friend") ft:put("doc3", "world peace now") local stats = ft:stats() testimony.assert_equal(3, stats.doc_count) testimony.assert_true(stats.unique_terms > 0) testimony.assert_true(stats.avg_doc_len > 0) testimony.assert_true(stats.total_doc_len > 0) db:close() std.fs.remove(path) end) testify:that("scan iterates all documents", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("a1", "first document") ft:put("a2", "second document") ft:put("b1", "third document") local docs = {} for doc_id, text in ft:scan() do docs[doc_id] = text end testimony.assert_equal("first document", docs["a1"]) testimony.assert_equal("second document", docs["a2"]) testimony.assert_equal("third document", docs["b1"]) db:close() std.fs.remove(path) end) testify:that("scan with prefix filters documents", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("rfc8446", "tls 1.3 spec") ft:put("rfc5681", "tcp congestion") ft:put("draft01", "some draft") local rfc_docs = {} for doc_id, text in ft:scan("rfc") do rfc_docs[doc_id] = text end testimony.assert_not_nil(rfc_docs["rfc8446"]) testimony.assert_not_nil(rfc_docs["rfc5681"]) testimony.assert_nil(rfc_docs["draft01"]) db:close() std.fs.remove(path) end) testify:that("count returns correct document count", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("rfc1", "one") ft:put("rfc2", "two") ft:put("draft1", "three") testimony.assert_equal(3, ft:count()) testimony.assert_equal(2, ft:count("rfc")) testimony.assert_equal(1, ft:count("draft")) testimony.assert_equal(0, ft:count("xyz")) db:close() std.fs.remove(path) end) testify:that("rejects doc_id with null bytes", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") local ok, err = ft:put("bad\x00id", "text") testimony.assert_nil(ok) testimony.assert_true(err:find("null") ~= nil) db:close() std.fs.remove(path) end) testify:that("rejects empty doc_id", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") local ok, err = ft:put("", "text") testimony.assert_nil(ok) db:close() std.fs.remove(path) end) testify:that("del on non-existent doc returns error", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") local ok, err = ft:del("nonexistent") testimony.assert_nil(ok) testimony.assert_equal("not found", err) db:close() std.fs.remove(path) end) local function remove_reverse_map(db, doc_id) local txn = db.__state.handle:txn_begin(false) testimony.assert_not_nil(txn) local root = txn:meta().keyspace_root_pgno local ks_root = core.unpack_u32(txn:btree_get(root, "corpus")) local new_ks_root, derr = txn:btree_del(ks_root, "corpus\x04" .. doc_id) testimony.assert_not_nil(new_ks_root, derr) local new_root = txn:btree_put(root, "corpus", core.pack_u32(new_ks_root), 1, 0) testimony.assert_not_nil(new_root) if new_root ~= root then txn:set_ks_root(new_root) end local ok, err = txn:commit() testimony.assert_not_nil(ok, err) end testify:that("del reports corrupt index when reverse map is missing", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("doc1", "hello corrupt world") local before = ft:stats() testimony.assert_equal(1, before.doc_count) -- Simulate corruption: document text remains but reverse map is gone. remove_reverse_map(db, "doc1") local ok, err = ft:del("doc1") testimony.assert_nil(ok) testimony.assert_equal("corrupt full-text index: missing reverse map", err) local after = ft:stats() testimony.assert_equal(before.doc_count, after.doc_count) testimony.assert_equal(before.total_doc_len, after.total_doc_len) testimony.assert_true(ft:exists("doc1")) db:close() std.fs.remove(path) end) testify:that("put reports corrupt index when updating doc without reverse map", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:put("doc1", "hello corrupt world") local before = ft:stats() testimony.assert_equal(1, before.doc_count) -- Simulate corruption: document text remains but reverse map is gone. remove_reverse_map(db, "doc1") local ok, err = ft:put("doc1", "replacement text") testimony.assert_nil(ok) testimony.assert_equal("corrupt full-text index: missing reverse map", err) testimony.assert_equal("hello corrupt world", ft:get("doc1")) local after = ft:stats() testimony.assert_equal(before.doc_count, after.doc_count) testimony.assert_equal(before.total_doc_len, after.total_doc_len) db:close() std.fs.remove(path) end) testify:that("paused indexing permits stored document without reverse map", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus") ft:pause_indexing() local ok, err = ft:put("doc1", "first text") testimony.assert_not_nil(ok, err) ok, err = ft:put("doc1", "second text") testimony.assert_not_nil(ok, err) testimony.assert_equal("second text", ft:get("doc1")) ok, err = ft:rebuild_index() testimony.assert_not_nil(ok, err) local stats = ft:stats() testimony.assert_equal(1, stats.doc_count) local results = ft:search("second") testimony.assert_equal(1, #results) testimony.assert_equal("doc1", results[1].key) db:close() std.fs.remove(path) end) testify:that("custom lua analyzer is used", function() local path = tmp_path() local db = mneme.open(path, { sync = "none" }) local ft = db:ft_keyspace("corpus", { analyzer = function(text) -- Simple analyzer: split on spaces, uppercase everything local terms = {} for word in text:gmatch("%S+") do terms[#terms + 1] = word:upper() end return terms end, analyzer_name = "upper_split", }) ft:put("doc1", "hello world") -- The custom analyzer should have produced HELLO and WORLD as terms -- Search should use the same analyzer local results = ft:search("hello") testimony.assert_equal(1, #results) testimony.assert_equal("doc1", results[1].key) db:close() std.fs.remove(path) end) testify:conclude()