Compare commits
No commits in common. "cf301737996e6446206c3439519ca5b230af5477" and "3772eeb6a5c60e2c1d12591f316421e02e8969ed" have entirely different histories.
cf30173799
...
3772eeb6a5
115 changed files with 1142 additions and 13872 deletions
11
.gitattributes
vendored
11
.gitattributes
vendored
|
|
@ -4,14 +4,3 @@
|
||||||
*.bat text eol=crlf
|
*.bat text eol=crlf
|
||||||
*.cmd text eol=crlf
|
*.cmd text eol=crlf
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
# The extraction corpus's committed fixtures. `.doc`/`.xls`/`.ppt` are OLE2
|
|
||||||
# compound files and `.flac` is an audio stream: an EOL filter applied to any
|
|
||||||
# of them corrupts the file, and `text` detection is a heuristic this does not
|
|
||||||
# need to rely on. `*.bat` above is `text eol=crlf` crate-wide, which would
|
|
||||||
# otherwise catch the `.bat` the corpus generates — but that one is written at
|
|
||||||
# test time and never enters git.
|
|
||||||
crates/quicksearch-core/tests/fixtures/**/*.doc binary
|
|
||||||
crates/quicksearch-core/tests/fixtures/**/*.xls binary
|
|
||||||
crates/quicksearch-core/tests/fixtures/**/*.ppt binary
|
|
||||||
crates/quicksearch-core/tests/fixtures/**/*.flac binary
|
|
||||||
|
|
|
||||||
480
Cargo.lock
generated
480
Cargo.lock
generated
|
|
@ -86,7 +86,7 @@ dependencies = [
|
||||||
"android-properties",
|
"android-properties",
|
||||||
"bitflags 2.13.1",
|
"bitflags 2.13.1",
|
||||||
"cc",
|
"cc",
|
||||||
"jni",
|
"jni 0.22.4",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"ndk",
|
"ndk",
|
||||||
|
|
@ -463,9 +463,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.3"
|
version = "3.16.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
|
|
@ -501,9 +501,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.12.1"
|
version = "1.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "calloop"
|
name = "calloop"
|
||||||
|
|
@ -577,6 +577,12 @@ dependencies = [
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfb"
|
name = "cfb"
|
||||||
version = "0.7.3"
|
version = "0.7.3"
|
||||||
|
|
@ -739,6 +745,16 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.10.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
|
|
@ -752,7 +768,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types 0.5.0",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -765,7 +781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -789,18 +805,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossbeam-channel"
|
name = "crossbeam-channel"
|
||||||
version = "0.5.16"
|
version = "0.5.13"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
|
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossbeam-utils",
|
"crossbeam-utils",
|
||||||
]
|
]
|
||||||
|
|
@ -955,20 +971,6 @@ dependencies = [
|
||||||
"litrs",
|
"litrs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "docx-rs"
|
|
||||||
version = "0.4.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "917eb338de7885a7bf6de3bb294ccbc6d64f0250430e40605d2f2e7e0ee4259a"
|
|
||||||
dependencies = [
|
|
||||||
"base64",
|
|
||||||
"quick-xml",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"thiserror 2.0.19",
|
|
||||||
"zip 8.6.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "downcast-rs"
|
name = "downcast-rs"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|
@ -1259,10 +1261,11 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "event-listener"
|
name = "event-listener"
|
||||||
version = "5.4.2"
|
version = "5.3.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"concurrent-queue",
|
||||||
"parking",
|
"parking",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
@ -1335,7 +1338,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
"zlib-rs",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1427,15 +1429,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.34"
|
version = "0.3.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.34"
|
version = "0.3.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-lite"
|
name = "futures-lite"
|
||||||
|
|
@ -1452,26 +1454,26 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.34"
|
version = "0.3.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 2.0.66",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.34"
|
version = "0.3.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.34"
|
version = "0.3.30"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
|
|
@ -1479,6 +1481,7 @@ dependencies = [
|
||||||
"futures-task",
|
"futures-task",
|
||||||
"memchr",
|
"memchr",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"pin-utils",
|
||||||
"slab",
|
"slab",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1761,23 +1764,21 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
|
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"potential_utf",
|
|
||||||
"utf8_iter",
|
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_locale_core"
|
name = "icu_locid"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"litemap",
|
"litemap",
|
||||||
|
|
@ -1787,91 +1788,108 @@ dependencies = [
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer"
|
name = "icu_locid_transform"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
|
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
|
"icu_locid",
|
||||||
|
"icu_locid_transform_data",
|
||||||
|
"icu_provider",
|
||||||
|
"tinystr",
|
||||||
|
"zerovec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_locid_transform_data"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icu_normalizer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
|
||||||
|
dependencies = [
|
||||||
|
"displaydoc",
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_normalizer_data",
|
"icu_normalizer_data",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
"icu_provider",
|
"icu_provider",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
|
"utf16_iter",
|
||||||
|
"utf8_iter",
|
||||||
|
"write16",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_normalizer_data"
|
name = "icu_normalizer_data"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties"
|
name = "icu_properties"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
|
checksum = "1f8ac670d7422d7f76b32e17a5db556510825b29ec9154f235977c9caba61036"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_collections",
|
"icu_collections",
|
||||||
"icu_locale_core",
|
"icu_locid_transform",
|
||||||
"icu_properties_data",
|
"icu_properties_data",
|
||||||
"icu_provider",
|
"icu_provider",
|
||||||
"zerotrie",
|
"tinystr",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_properties_data"
|
name = "icu_properties_data"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
|
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_provider"
|
name = "icu_provider"
|
||||||
version = "2.3.0"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
|
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"icu_locale_core",
|
"icu_locid",
|
||||||
|
"icu_provider_macros",
|
||||||
|
"stable_deref_trait",
|
||||||
|
"tinystr",
|
||||||
"writeable",
|
"writeable",
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
"zerotrie",
|
|
||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "id3"
|
name = "icu_provider_macros"
|
||||||
version = "1.17.1"
|
version = "1.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "24993fcabcbc07c8ac076a8e62db8593d1a5c4dbe81d57e531d2b7cb7f737380"
|
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.13.1",
|
"proc-macro2",
|
||||||
"byteorder",
|
"quote",
|
||||||
"flate2",
|
"syn 2.0.66",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "1.1.0"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
checksum = "4716a3a0933a1d01c2f72450e89596eb51dd34ef3c211ccd875acdf1f8fe47ed"
|
||||||
dependencies = [
|
|
||||||
"idna_adapter",
|
|
||||||
"smallvec",
|
|
||||||
"utf8_iter",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna_adapter"
|
|
||||||
version = "1.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"icu_normalizer",
|
"icu_normalizer",
|
||||||
"icu_properties",
|
"icu_properties",
|
||||||
|
"smallvec",
|
||||||
|
"utf8_iter",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1962,6 +1980,22 @@ version = "1.0.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jni"
|
||||||
|
version = "0.21.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||||
|
dependencies = [
|
||||||
|
"cesu8",
|
||||||
|
"cfg-if",
|
||||||
|
"combine",
|
||||||
|
"jni-sys 0.3.0",
|
||||||
|
"log",
|
||||||
|
"thiserror 1.0.61",
|
||||||
|
"walkdir",
|
||||||
|
"windows-sys 0.45.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.22.4"
|
version = "0.22.4"
|
||||||
|
|
@ -2157,9 +2191,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.3"
|
version = "0.7.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litrs"
|
name = "litrs"
|
||||||
|
|
@ -2270,16 +2304,6 @@ dependencies = [
|
||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "metaflac"
|
|
||||||
version = "0.2.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fdf25a3451319c52a4a56d956475fbbb763bfb8420e2187d802485cb0fd8d965"
|
|
||||||
dependencies = [
|
|
||||||
"byteorder",
|
|
||||||
"hex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
|
|
@ -2908,18 +2932,6 @@ dependencies = [
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pdf-writer"
|
|
||||||
version = "0.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.13.1",
|
|
||||||
"itoa",
|
|
||||||
"memchr",
|
|
||||||
"ryu",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "percent-encoding"
|
name = "percent-encoding"
|
||||||
version = "2.3.1"
|
version = "2.3.1"
|
||||||
|
|
@ -2952,6 +2964,12 @@ version = "0.2.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
|
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-utils"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "piper"
|
name = "piper"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
|
|
@ -3027,15 +3045,6 @@ version = "0.14.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
|
checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "potential_utf"
|
|
||||||
version = "0.1.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
|
||||||
dependencies = [
|
|
||||||
"zerovec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -3078,59 +3087,60 @@ version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-xml"
|
||||||
|
version = "0.31.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
|
||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quicksearch-core"
|
name = "quicksearch-core"
|
||||||
version = "1.1.3"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"cfb",
|
"cfb",
|
||||||
"chardetng",
|
"chardetng",
|
||||||
"crc32fast",
|
|
||||||
"ctrlc",
|
"ctrlc",
|
||||||
"divan",
|
"divan",
|
||||||
"docx-rs",
|
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"getrandom 0.2.15",
|
"getrandom 0.2.15",
|
||||||
"globset",
|
"globset",
|
||||||
"id3",
|
|
||||||
"infer",
|
"infer",
|
||||||
"libc",
|
"libc",
|
||||||
"lofty",
|
"lofty",
|
||||||
"memchr",
|
"memchr",
|
||||||
"metaflac",
|
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify",
|
"notify",
|
||||||
"pdf-extract",
|
"pdf-extract",
|
||||||
"pdf-writer",
|
"quick-xml 0.31.0",
|
||||||
"quick-xml",
|
|
||||||
"regex",
|
"regex",
|
||||||
"regex-syntax",
|
|
||||||
"rtf-parser",
|
"rtf-parser",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rust_xlsxwriter",
|
|
||||||
"serde",
|
"serde",
|
||||||
"sha2",
|
"sha2",
|
||||||
"toml",
|
"toml",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.52.0",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zip 0.6.6",
|
"zip",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quicksearch-gui"
|
name = "quicksearch-gui"
|
||||||
version = "1.1.3"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ashpd",
|
"ashpd",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|
@ -3154,9 +3164,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.36"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
|
|
@ -3366,6 +3376,8 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rtf-parser"
|
name = "rtf-parser"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c3147b4eb521eae5e29b781bdc30ab98bbaed784bfcb8376cda60ba4b2e0e3d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
@ -3395,15 +3407,6 @@ dependencies = [
|
||||||
"sqlite-wasm-rs",
|
"sqlite-wasm-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rust_xlsxwriter"
|
|
||||||
version = "0.98.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "093d730a6f64f9620e047b2de2876e61a691fbcaca3aaf436edade9b897c7a27"
|
|
||||||
dependencies = [
|
|
||||||
"zip 8.6.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -3457,12 +3460,6 @@ version = "1.0.23"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ryu"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
|
|
@ -3520,19 +3517,6 @@ dependencies = [
|
||||||
"syn 3.0.3",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_json"
|
|
||||||
version = "1.0.151"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
|
||||||
dependencies = [
|
|
||||||
"itoa",
|
|
||||||
"memchr",
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
"zmij",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.19"
|
version = "0.1.19"
|
||||||
|
|
@ -3880,9 +3864,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.7.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"displaydoc",
|
"displaydoc",
|
||||||
"zerovec",
|
"zerovec",
|
||||||
|
|
@ -4010,12 +3994,6 @@ dependencies = [
|
||||||
"pom",
|
"pom",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typed-path"
|
|
||||||
version = "0.12.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.17.0"
|
version = "1.17.0"
|
||||||
|
|
@ -4080,9 +4058,9 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "url"
|
name = "url"
|
||||||
version = "2.5.4"
|
version = "2.5.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
|
checksum = "f7c25da092f0a868cdf09e8674cd3b7ef3a7d92a24253e663a2fb85e2496de56"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"form_urlencoded",
|
"form_urlencoded",
|
||||||
"idna",
|
"idna",
|
||||||
|
|
@ -4096,6 +4074,12 @@ version = "2.1.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf16_iter"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8_iter"
|
name = "utf8_iter"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -4321,7 +4305,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml",
|
"quick-xml 0.41.0",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -4359,15 +4343,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webbrowser"
|
name = "webbrowser"
|
||||||
version = "1.2.2"
|
version = "1.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ef62a3d5f7b2411119a11b6f62570dbff91d7105e011a20fb83fbf8f5761c40f"
|
checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jni",
|
"core-foundation 0.10.1",
|
||||||
|
"jni 0.21.1",
|
||||||
"log",
|
"log",
|
||||||
"ndk-context",
|
"ndk-context",
|
||||||
"objc2 0.6.4",
|
"objc2 0.6.4",
|
||||||
"objc2-app-kit 0.3.2",
|
|
||||||
"objc2-foundation 0.3.2",
|
"objc2-foundation 0.3.2",
|
||||||
"url",
|
"url",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
|
@ -4566,6 +4550,15 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.45.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.48.0"
|
version = "0.48.0"
|
||||||
|
|
@ -4611,6 +4604,21 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.42.2",
|
||||||
|
"windows_aarch64_msvc 0.42.2",
|
||||||
|
"windows_i686_gnu 0.42.2",
|
||||||
|
"windows_i686_msvc 0.42.2",
|
||||||
|
"windows_x86_64_gnu 0.42.2",
|
||||||
|
"windows_x86_64_gnullvm 0.42.2",
|
||||||
|
"windows_x86_64_msvc 0.42.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4659,6 +4667,12 @@ dependencies = [
|
||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4677,6 +4691,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4695,6 +4715,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4725,6 +4751,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4743,6 +4775,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4761,6 +4799,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4779,6 +4823,12 @@ version = "0.53.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.48.5"
|
version = "0.48.5"
|
||||||
|
|
@ -4812,7 +4862,7 @@ dependencies = [
|
||||||
"calloop 0.13.0",
|
"calloop 0.13.0",
|
||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"core-foundation",
|
"core-foundation 0.9.4",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"cursor-icon",
|
"cursor-icon",
|
||||||
"dpi",
|
"dpi",
|
||||||
|
|
@ -4873,10 +4923,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "writeable"
|
name = "write16"
|
||||||
version = "0.6.4"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "writeable"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "x11-dl"
|
name = "x11-dl"
|
||||||
|
|
@ -4953,10 +5009,11 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.7.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
"stable_deref_trait",
|
"stable_deref_trait",
|
||||||
"yoke-derive",
|
"yoke-derive",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
|
|
@ -4964,9 +5021,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke-derive"
|
name = "yoke-derive"
|
||||||
version = "0.8.2"
|
version = "0.7.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
|
checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -5058,18 +5115,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom"
|
name = "zerofrom"
|
||||||
version = "0.1.8"
|
version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerofrom-derive",
|
"zerofrom-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerofrom-derive"
|
name = "zerofrom-derive"
|
||||||
version = "0.1.7"
|
version = "0.1.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -5097,22 +5154,11 @@ dependencies = [
|
||||||
"syn 2.0.66",
|
"syn 2.0.66",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zerotrie"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
|
||||||
dependencies = [
|
|
||||||
"displaydoc",
|
|
||||||
"yoke",
|
|
||||||
"zerofrom",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec"
|
name = "zerovec"
|
||||||
version = "0.11.8"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
checksum = "bb2cc8827d6c0994478a15c53f374f46fbd41bea663d809b14744bc42e6b109c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yoke",
|
"yoke",
|
||||||
"zerofrom",
|
"zerofrom",
|
||||||
|
|
@ -5121,13 +5167,13 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerovec-derive"
|
name = "zerovec-derive"
|
||||||
version = "0.11.5"
|
version = "0.10.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9"
|
checksum = "97cf56601ee5052b4417d90c8755c6683473c926039908196cf35d99f893ebe7"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.3",
|
"syn 2.0.66",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5142,44 +5188,6 @@ dependencies = [
|
||||||
"flate2",
|
"flate2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zip"
|
|
||||||
version = "8.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
|
|
||||||
dependencies = [
|
|
||||||
"crc32fast",
|
|
||||||
"flate2",
|
|
||||||
"indexmap",
|
|
||||||
"memchr",
|
|
||||||
"typed-path",
|
|
||||||
"zopfli",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zlib-rs"
|
|
||||||
version = "0.6.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zmij"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zopfli"
|
|
||||||
version = "0.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"crc32fast",
|
|
||||||
"log",
|
|
||||||
"simd-adler32",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zstd"
|
name = "zstd"
|
||||||
version = "0.13.3"
|
version = "0.13.3"
|
||||||
|
|
|
||||||
29
Cargo.toml
29
Cargo.toml
|
|
@ -4,15 +4,34 @@ members = [
|
||||||
"crates/quicksearch-core",
|
"crates/quicksearch-core",
|
||||||
"crates/quicksearch-gui",
|
"crates/quicksearch-gui",
|
||||||
]
|
]
|
||||||
# The `vendor/` crates are deliberately NOT members: they are third-party code which we patched
|
# `vendor/pdf-extract` is deliberately NOT a member: it is a third-party crate
|
||||||
exclude = ["vendor/pdf-extract", "vendor/rtf-parser"]
|
# carried here for one patch, not part of this workspace's lints, tests or
|
||||||
|
# release profile. `[patch.crates-io]` below is what makes the dependency graph
|
||||||
|
# resolve to it.
|
||||||
|
exclude = ["vendor/pdf-extract"]
|
||||||
|
|
||||||
|
# pdf-extract 0.12.0, with the two unbounded recursions in it bounded.
|
||||||
|
#
|
||||||
|
# `get_inherited` follows `/Parent` and `process_stream`'s `Do` arm follows
|
||||||
|
# Form XObjects, neither with a depth counter or a visited set. A page whose
|
||||||
|
# `/Parent` is itself, or an XObject whose content stream draws itself, walks
|
||||||
|
# the stack until it hits the guard page — and a stack overflow is not a panic
|
||||||
|
# that `catch_unwind` can contain (`extract/pdf.rs` has one, for the parser's
|
||||||
|
# ordinary panics): Rust's handler calls `abort()`, so a ~600-byte file kills
|
||||||
|
# the process. It recurs on every run, because the row keeps
|
||||||
|
# `content_state = 0` and the feeder selects exactly those; and the live
|
||||||
|
# watcher re-extracts on-screen rows on the GUI thread, so such a file crashes
|
||||||
|
# the app when it merely appears in a result list.
|
||||||
|
#
|
||||||
|
# Vendored rather than forked-by-URL so the build stays offline, `--locked`
|
||||||
|
# keeps meaning what it means, and the cross-compile job needs no new host.
|
||||||
|
# The patch is marked LOCAL PATCH in the source and is upstreamable; the crate
|
||||||
|
# is MIT and the copy is recorded in `packaging/copyright`.
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
pdf-extract = { path = "vendor/pdf-extract" } # Patched unbounded reads which could blow up on malformed files
|
pdf-extract = { path = "vendor/pdf-extract" }
|
||||||
rtf-parser = { path = "vendor/rtf-parser" } # Patched a parsing error which occurs on UTF-16 characters
|
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "1.1.4"
|
version = "1.1.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
authors = ["Jeremy <jeremy@karsttech.com>"]
|
authors = ["Jeremy <jeremy@karsttech.com>"]
|
||||||
|
|
|
||||||
60
README.md
60
README.md
|
|
@ -223,20 +223,7 @@ inside that folder.
|
||||||
|
|
||||||
### GUI
|
### GUI
|
||||||
|
|
||||||
`quicksearch` with no query arguments opens the app. **One window at a
|
`quicksearch` with no query arguments opens the app:
|
||||||
time**: a second launch reports that QuickSearch is already running and
|
|
||||||
exits, because two processes indexing one database corrupt it. Terminal
|
|
||||||
search (`quicksearch <query>`) only reads and keeps working while the
|
|
||||||
window is open. The guard is a kernel lock on `<database_path>.lock`, held
|
|
||||||
by the running process — so a crash or a power cut releases it, and the
|
|
||||||
leftover file never locks you out. The lock follows `database_path`: point
|
|
||||||
Settings at a different index and it moves with you, and if that index
|
|
||||||
belongs to another instance the change is refused rather than written, so a
|
|
||||||
path you cannot open can never end up in the config file. A path naming
|
|
||||||
some *other* program's SQLite database is refused for the same reason: the
|
|
||||||
file there is created when missing and replaced when it is an index from an
|
|
||||||
older layout of QuickSearch's own, but anything else is left alone and the
|
|
||||||
error names the tables that identified it.
|
|
||||||
|
|
||||||
- **Search**: results appear as you type; every keystroke cancels the
|
- **Search**: results appear as you type; every keystroke cancels the
|
||||||
previous search. One checkbox enables the two fuzzy passes, and once a
|
previous search. One checkbox enables the two fuzzy passes, and once a
|
||||||
|
|
@ -461,12 +448,6 @@ combination, plus one key, joined with `+`. An empty string switches it
|
||||||
off. A value that is not a shortcut is not a config error — the app loads,
|
off. A value that is not a shortcut is not a config error — the app loads,
|
||||||
says so on the Settings tab, and runs without one.
|
says so on the Settings tab, and runs without one.
|
||||||
|
|
||||||
Numbers behave the same way. A hand-edited value outside the range a
|
|
||||||
setting can work in — `display_limit = 0`, which would make every search
|
|
||||||
return nothing — is clamped to the nearest workable one with a warning,
|
|
||||||
never rejected: a typo in a text file must not stop the app starting. The
|
|
||||||
ranges are in `config_example.toml` beside each setting that has one.
|
|
||||||
|
|
||||||
`[ui] color_scheme` is `dark` (the default) or `light`, changeable on the
|
`[ui] color_scheme` is `dark` (the default) or `light`, changeable on the
|
||||||
Settings tab and applied without a restart. It does not follow the
|
Settings tab and applied without a restart. It does not follow the
|
||||||
desktop's own light/dark setting: on Linux nothing in the window system
|
desktop's own light/dark setting: on Linux nothing in the window system
|
||||||
|
|
@ -510,14 +491,7 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
|
||||||
`wal_checkpoint(TRUNCATE)` every `processing.maximum_wal_size` bytes of
|
`wal_checkpoint(TRUNCATE)` every `processing.maximum_wal_size` bytes of
|
||||||
log, because SQLite's own autocheckpoint can only reset the log at an
|
log, because SQLite's own autocheckpoint can only reset the log at an
|
||||||
instant no reader holds it — and a run keeps a reader per root querying
|
instant no reader holds it — and a run keeps a reader per root querying
|
||||||
throughout, so left alone the log grows for the whole run. That threshold
|
throughout, so left alone the log grows for the whole run. `files` holds
|
||||||
is lowered to fit the volume when free space is short, and a run stops
|
|
||||||
with an error rather than fill the disk: SQLite reaches the wal-index
|
|
||||||
through an mmap, and a page fault the filesystem cannot back arrives as
|
|
||||||
**SIGBUS**, which no `Result` can catch. The index's own files are, for
|
|
||||||
the same reason, never walked into — hashing one means opening it, and on
|
|
||||||
POSIX closing any descriptor on an inode cancels every advisory lock the
|
|
||||||
process holds on it, SQLite's documented corruption hazard. `files` holds
|
|
||||||
metadata (name, path, size, mtime, hash, MIME/type bitmask, content
|
metadata (name, path, size, mtime, hash, MIME/type bitmask, content
|
||||||
state); `searchabletext` is a *contentless* FTS5 table over one column,
|
state); `searchabletext` is a *contentless* FTS5 table over one column,
|
||||||
the document body (postings only, configurable tokenizer, trigram by
|
the document body (postings only, configurable tokenizer, trigram by
|
||||||
|
|
@ -598,7 +572,7 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
|
||||||
tokenizing can do; the scheduling shares it fairly and keeps the walk
|
tokenizing can do; the scheduling shares it fairly and keeps the walk
|
||||||
first, it does not raise it. Every run ends — whether
|
first, it does not raise it. Every run ends — whether
|
||||||
it completed or was stopped — with an optimize pass on its own connection:
|
it completed or was stopped — with an optimize pass on its own connection:
|
||||||
checkpoint, VACUUM if the file has at least 20% slack to reclaim, `PRAGMA
|
checkpoint, VACUUM if the file has at least 10% slack to reclaim, `PRAGMA
|
||||||
optimize`, checkpoint again. Progress streams through a polled
|
optimize`, checkpoint again. Progress streams through a polled
|
||||||
`IndexingStatus`, which reads `Optimizing` for the duration of that pass —
|
`IndexingStatus`, which reads `Optimizing` for the duration of that pass —
|
||||||
and `Preparing` for everything a run does before its first file is walked:
|
and `Preparing` for everything a run does before its first file is walked:
|
||||||
|
|
@ -700,15 +674,13 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
|
||||||
to that by construction, because a buffer allocated and freed inside one loop
|
to that by construction, because a buffer allocated and freed inside one loop
|
||||||
iteration never moves RSS. `DocDecoder` must therefore never fall back to a
|
iteration never moves RSS. `DocDecoder` must therefore never fall back to a
|
||||||
per-row allocating decode, and there is a trap waiting there:
|
per-row allocating decode, and there is a trap waiting there:
|
||||||
the indexer writes through `DocEncoder`, i.e. `zstd::bulk::Compressor` and
|
`zstd::encode_all`, which the indexer writes with, is *stream*-based and so
|
||||||
so `ZSTD_compress2`, which is handed the whole document at once and records
|
records no content size in the frame header, meaning
|
||||||
its length in the frame header. `get_frame_content_size` therefore returns
|
`get_frame_content_size` returns `None` for every row this ever sees. The
|
||||||
`Some` for every row this writer produced, the reservation is normally
|
"cannot happen" branch is the only branch. Sizing the buffer from the header
|
||||||
exact, and the growth loop below it is the fallback for a frame written by
|
and handing the `None` case to `zstd::decode_all` looks obviously right and
|
||||||
a *stream* encoder rather than the only branch. What must not come back is
|
costs ~2.4 MiB per document, because `decode_all` builds a streaming decoder
|
||||||
handing the `None` case to `zstd::decode_all`, which looks obviously right
|
per call — 27 of the 30 GiB a fuzzy search moved. Growing this buffer and
|
||||||
and costs ~2.4 MiB per document because it builds a streaming decoder per
|
|
||||||
call — 27 of the 30 GiB a fuzzy search moved. Growing this buffer and
|
|
||||||
keeping it is what makes decoding a row allocate nothing at all.
|
keeping it is what makes decoding a row allocate nothing at all.
|
||||||
Measured over the same 77k-file index, per query:
|
Measured over the same 77k-file index, per query:
|
||||||
`cascade` 582 → 14 MiB, `function` 6.0 GiB → 29 MiB, `--fuzzy cascade`
|
`cascade` 582 → 14 MiB, `function` 6.0 GiB → 29 MiB, `--fuzzy cascade`
|
||||||
|
|
@ -895,16 +867,8 @@ microseconds regardless of row count.
|
||||||
program, so the installer needs no Windows runner either.
|
program, so the installer needs no Windows runner either.
|
||||||
- New extractors: implement `extract::Extractor` and register it in
|
- New extractors: implement `extract::Extractor` and register it in
|
||||||
`Registry::default_set()` — order matters, the first extractor whose
|
`Registry::default_set()` — order matters, the first extractor whose
|
||||||
`supports` accepts a MIME wins. Then add the format to the corpus in
|
`supports` accepts a MIME wins. New cascade behavior: `search/cascade.rs`
|
||||||
`crates/quicksearch-core/tests/corpus/`, which writes a randomized lipsum
|
documents the rank invariants that keep streamed results append-only.
|
||||||
document in every supported format and asserts the planted text comes back —
|
|
||||||
at the extractor, through the walk-time head path, and end to end through
|
|
||||||
indexing and search (`tests/extraction_corpus.rs`). Its rule is that no
|
|
||||||
corpus file may be written by the library that reads it back, since a writer
|
|
||||||
and reader sharing a wrong assumption agree with each other; the module docs
|
|
||||||
say which library writes what and why. New cascade behavior:
|
|
||||||
`search/cascade.rs` documents the rank invariants that keep streamed results
|
|
||||||
append-only.
|
|
||||||
- `packaging/capture.sh`: regenerates the website assets — `search.webm`,
|
- `packaging/capture.sh`: regenerates the website assets — `search.webm`,
|
||||||
`manage-indexing.webm`, `duplicates.png`, `query-highlight.png` — into
|
`manage-indexing.webm`, `duplicates.png`, `query-highlight.png` — into
|
||||||
`packaging/captures/` (gitignored). It builds the GUI with the `capture`
|
`packaging/captures/` (gitignored). It builds the GUI with the `capture`
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,6 @@ indexing_paths = ["~"]
|
||||||
# backslashes need no escaping, and keep the index out of a roaming
|
# backslashes need no escaping, and keep the index out of a roaming
|
||||||
# profile — it is far too large to synchronise:
|
# profile — it is far too large to synchronise:
|
||||||
# database_path = 'C:\Users\you\AppData\Local\quicksearch\index.sqlite'
|
# database_path = 'C:\Users\you\AppData\Local\quicksearch\index.sqlite'
|
||||||
#
|
|
||||||
# The file here is created if it is missing, and replaced if it is an index
|
|
||||||
# from an older layout of QuickSearch's own. A SQLite database belonging to
|
|
||||||
# some other program is neither: pointing this at one is refused with an
|
|
||||||
# error naming the tables it found, and nothing is deleted.
|
|
||||||
database_path = "~/.local/share/quicksearch/index.sqlite"
|
database_path = "~/.local/share/quicksearch/index.sqlite"
|
||||||
|
|
||||||
[indexing]
|
[indexing]
|
||||||
|
|
@ -79,11 +74,6 @@ content_extensions = []
|
||||||
# The Windows defaults add: "$RECYCLE.BIN", "System Volume Information",
|
# The Windows defaults add: "$RECYCLE.BIN", "System Volume Information",
|
||||||
# "pagefile.sys", "hiberfil.sys", "swapfile.sys", "Thumbs.db",
|
# "pagefile.sys", "hiberfil.sys", "swapfile.sys", "Thumbs.db",
|
||||||
# "desktop.ini".
|
# "desktop.ini".
|
||||||
#
|
|
||||||
# The index itself (database_path and its -wal/-shm/-journal/.lock
|
|
||||||
# siblings) is always skipped and needs no pattern here — not as a
|
|
||||||
# convenience but because merely opening one of those files to hash it
|
|
||||||
# would cancel SQLite's locks on it for the whole process.
|
|
||||||
ignore_patterns = [".git", "node_modules", "*.tmp", ".venv", "venv"]
|
ignore_patterns = [".git", "node_modules", "*.tmp", ".venv", "venv"]
|
||||||
|
|
||||||
# Worth adding by hand if you index a whole Windows drive rather than just
|
# Worth adding by hand if you index a whole Windows drive rather than just
|
||||||
|
|
@ -112,15 +102,10 @@ ignore_patterns = [".git", "node_modules", "*.tmp", ".venv", "venv"]
|
||||||
# images: a fixed-size VHD stores its unique footer at the end of the
|
# images: a fixed-size VHD stores its unique footer at the end of the
|
||||||
# file, and a freshly pre-allocated raw/qcow2/VMDK image is all zeros at
|
# file, and a freshly pre-allocated raw/qcow2/VMDK image is all zeros at
|
||||||
# the head until it is partitioned.
|
# the head until it is partitioned.
|
||||||
#
|
|
||||||
# Clamped to 262..1048576 on load: below 262 the longest magic-number
|
|
||||||
# matcher cannot run, so file types stop being detectable by content, and
|
|
||||||
# above a megabyte the per-file head buffer stops being a head.
|
|
||||||
hash_length = 8192
|
hash_length = 8192
|
||||||
# Maximum extracted text stored per file (bytes).
|
# Maximum extracted text stored per file (bytes).
|
||||||
maximum_text_size = 262144
|
maximum_text_size = 262144
|
||||||
# Files larger than this skip text extraction entirely (bytes). Clamped to
|
# Files larger than this skip text extraction entirely (bytes).
|
||||||
# 1..4294967296 on load.
|
|
||||||
maximum_text_file_size = 2097152
|
maximum_text_file_size = 2097152
|
||||||
# Files per batch during walks / inserts / extraction.
|
# Files per batch during walks / inserts / extraction.
|
||||||
batch_size = 500
|
batch_size = 500
|
||||||
|
|
@ -128,9 +113,7 @@ batch_size = 500
|
||||||
# on (milliseconds). The time half of the knob whose row half is batch_size:
|
# on (milliseconds). The time half of the knob whose row half is batch_size:
|
||||||
# it bounds how long one root can hold up the others, so a root extracting
|
# it bounds how long one root can hold up the others, so a root extracting
|
||||||
# large documents cannot leave another root's walkers parked behind it. 0
|
# large documents cannot leave another root's walkers parked behind it. 0
|
||||||
# gives each turn one batch_size quantum and no more. Clamped to 0..10000
|
# gives each turn one batch_size quantum and no more.
|
||||||
# on load: unbounded, one root holds the writer for as long as it likes and
|
|
||||||
# every other root's walk waits behind it.
|
|
||||||
writer_turn_slice_ms = 100
|
writer_turn_slice_ms = 100
|
||||||
# Files per transaction for incremental FTS updates.
|
# Files per transaction for incremental FTS updates.
|
||||||
fts_update_batch_size = 1000
|
fts_update_batch_size = 1000
|
||||||
|
|
@ -141,13 +124,6 @@ fts_update_batch_size = 1000
|
||||||
# unattended the log grows for the whole run and can end up larger than the
|
# unattended the log grows for the whole run and can end up larger than the
|
||||||
# index. Set to 0 to disable forced checkpoints; any other value below
|
# index. Set to 0 to disable forced checkpoints; any other value below
|
||||||
# 16777216 is raised to it.
|
# 16777216 is raised to it.
|
||||||
#
|
|
||||||
# This is a stall-frequency knob, not a safety one: when the volume holding
|
|
||||||
# the index is short on space the indexer checkpoints sooner than asked —
|
|
||||||
# including when this is 0 — and stops the run with an error rather than
|
|
||||||
# fill the disk. Running out is not a clean failure; SQLite reaches its
|
|
||||||
# wal-index through a memory map, and a write the filesystem cannot back
|
|
||||||
# kills the process with SIGBUS instead of returning an error.
|
|
||||||
maximum_wal_size = 536870912
|
maximum_wal_size = 536870912
|
||||||
# FTS5 tokenizer: 'trigram' (substring matching, the default; gets
|
# FTS5 tokenizer: 'trigram' (substring matching, the default; gets
|
||||||
# remove_diacritics 1 appended), 'unicode61', 'porter', or a full FTS5
|
# remove_diacritics 1 appended), 'unicode61', 'porter', or a full FTS5
|
||||||
|
|
@ -215,9 +191,7 @@ fuzzy_default = false
|
||||||
# the fuzzy stages off. Above 3 is allowed but not recommended: matches
|
# the fuzzy stages off. Above 3 is allowed but not recommended: matches
|
||||||
# become dominated by coincidence and every fuzzy pass slows down.
|
# become dominated by coincidence and every fuzzy pass slows down.
|
||||||
fuzzy_max_edits = 2
|
fuzzy_max_edits = 2
|
||||||
# Hard cap on results per search (the GUI's scroll list length). Clamped to
|
# Hard cap on results per search (the GUI's scroll list length).
|
||||||
# 1..1000000 on load: at zero the cascade stops before its first pass, so
|
|
||||||
# every search returns nothing and calls the empty answer truncated.
|
|
||||||
display_limit = 1000
|
display_limit = 1000
|
||||||
# Results per streamed batch (latency/overhead knob, not a page size).
|
# Results per streamed batch (latency/overhead knob, not a page size).
|
||||||
results_per_page = 100
|
results_per_page = 100
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ walkdir = "2.5.0"
|
||||||
# containers (docx/xlsx/pptx/odt/ods/odp), whose entries are always deflate or
|
# containers (docx/xlsx/pptx/odt/ods/odp), whose entries are always deflate or
|
||||||
# stored, so zstd/bzip2/aes-crypto are all dead weight here.
|
# stored, so zstd/bzip2/aes-crypto are all dead weight here.
|
||||||
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
||||||
quick-xml = "0.41"
|
quick-xml = "0.31"
|
||||||
# OLE2 compound-file reader, for the pre-2007 binary Office formats
|
# OLE2 compound-file reader, for the pre-2007 binary Office formats
|
||||||
# (.doc/.xls/.ppt) whose text lives in named streams rather than a zip. Already
|
# (.doc/.xls/.ppt) whose text lives in named streams rather than a zip. Already
|
||||||
# in the lockfile transitively via infer, so naming it directly compiles
|
# in the lockfile transitively via infer, so naming it directly compiles
|
||||||
|
|
@ -51,19 +51,8 @@ encoding_rs = "0.8"
|
||||||
# UTF-8 nor BOM-marked. Its mandatory deps beyond encoding_rs are tiny
|
# UTF-8 nor BOM-marked. Its mandatory deps beyond encoding_rs are tiny
|
||||||
# (cfg-if, memchr, detone).
|
# (cfg-if, memchr, detone).
|
||||||
chardetng = "1.0"
|
chardetng = "1.0"
|
||||||
# RTF text extraction. Pure Rust; depends only on serde.
|
# RTF text extraction. Pure Rust; with the default `jsbindings` feature off
|
||||||
#
|
# (it exists for the crate's WASM build) it depends only on serde.
|
||||||
# Resolves to `vendor/rtf-parser` through the workspace's `[patch.crates-io]`,
|
|
||||||
# which is also where the reasons are written down: the stock lexer ended a
|
|
||||||
# control word at whitespace and nowhere else, which silently dropped indexed
|
|
||||||
# text from two shapes of document that LibreOffice and Word produce, and the
|
|
||||||
# parser panicked on a malformed `\uN` escape. The version here still names
|
|
||||||
# 0.4 because that is what the patch is against.
|
|
||||||
#
|
|
||||||
# `default-features = false` is redundant against the vendored copy, which has
|
|
||||||
# no features left to disable — the `jsbindings` one existed for the crate's
|
|
||||||
# WebAssembly build and was deleted. It stays so that dropping the patch falls
|
|
||||||
# back to a registry build that does not pull wasm-bindgen.
|
|
||||||
rtf-parser = { version = "0.4", default-features = false }
|
rtf-parser = { version = "0.4", default-features = false }
|
||||||
# `lopdf` is deliberately NOT declared here. `pdf-extract` re-exports it
|
# `lopdf` is deliberately NOT declared here. `pdf-extract` re-exports it
|
||||||
# (`pub use lopdf::*`), and `extract/pdf.rs` reaches it that way. Naming it
|
# (`pub use lopdf::*`), and `extract/pdf.rs` reaches it that way. Naming it
|
||||||
|
|
@ -79,11 +68,6 @@ ctrlc = "3.4"
|
||||||
zstd = "0.13"
|
zstd = "0.13"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
# Required-literal extraction for the `regex:` prefilter: the same analysis the
|
|
||||||
# regex engine does to pick its own prefilter, reused to narrow which rows the
|
|
||||||
# regex passes ever see. Already in the lockfile transitively (via `regex`), so
|
|
||||||
# naming it directly compiles nothing new.
|
|
||||||
regex-syntax = "0.8"
|
|
||||||
# SIMD substring search for the full-text passes. `str::match_indices` uses
|
# SIMD substring search for the full-text passes. `str::match_indices` uses
|
||||||
# std's Two-Way searcher, which has no vector prefilter: measured against a
|
# std's Two-Way searcher, which has no vector prefilter: measured against a
|
||||||
# 256 KiB body (`benches/search.rs`, group `substring`) it runs 111 µs where
|
# 256 KiB body (`benches/search.rs`, group `substring`) it runs 111 µs where
|
||||||
|
|
@ -117,10 +101,6 @@ windows-sys = { version = "0.52", features = [
|
||||||
# the directory-count path passes null for it.
|
# the directory-count path passes null for it.
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
"Win32_Storage_FileSystem",
|
"Win32_Storage_FileSystem",
|
||||||
# OVERLAPPED, which `LockFileEx` takes for the index's instance lock.
|
|
||||||
# `LockFileEx` and `GetDiskFreeSpaceExW` themselves are in
|
|
||||||
# Win32_Storage_FileSystem above.
|
|
||||||
"Win32_System_IO",
|
|
||||||
"Win32_System_Threading",
|
"Win32_System_Threading",
|
||||||
"Win32_System_WindowsProgramming",
|
"Win32_System_WindowsProgramming",
|
||||||
] }
|
] }
|
||||||
|
|
@ -133,44 +113,6 @@ windows-sys = { version = "0.52", features = [
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
divan = "0.1"
|
divan = "0.1"
|
||||||
|
|
||||||
# Writers for `tests/extraction_corpus.rs`. Every one of these is chosen to be
|
|
||||||
# a *different implementation* from the reader it feeds: a fixture built with
|
|
||||||
# the same library that parses it can only prove the two agree with each other,
|
|
||||||
# not that either agrees with the format. The unit tests in `src/extract/` do
|
|
||||||
# use the readers' own libraries, deliberately — they aim at malformed input,
|
|
||||||
# where the point is to control every byte. This set aims at well-formed input
|
|
||||||
# from a foreign producer, which is the other half.
|
|
||||||
#
|
|
||||||
# None of these reach the release binary: dev-dependencies are linked into test
|
|
||||||
# and bench targets only.
|
|
||||||
#
|
|
||||||
# `.doc`/`.xls`/`.ppt` are absent because nothing in Rust writes OLE2 compound
|
|
||||||
# files but `cfb`, which is the reader. Those three are committed fixtures from
|
|
||||||
# LibreOffice instead; see `tests/fixtures/legacy/`.
|
|
||||||
#
|
|
||||||
# docx: writes its own OOXML, and carries `zip` 8.x — a different major from
|
|
||||||
# the 0.6 our reader uses, so even the container bytes come from elsewhere.
|
|
||||||
# The default `image` feature pulls the whole `image` crate for embedding
|
|
||||||
# pictures, which nothing here does.
|
|
||||||
docx-rs = { version = "0.4", default-features = false }
|
|
||||||
# xlsx: builds a real shared-string table, which is the path `extract_xlsx`
|
|
||||||
# actually cares about.
|
|
||||||
rust_xlsxwriter = { version = "0.98", default-features = false }
|
|
||||||
# pdf: typst's low-level writer. No `lopdf` anywhere in its tree, which is what
|
|
||||||
# makes it independent of `pdf-extract`.
|
|
||||||
pdf-writer = "0.15"
|
|
||||||
# audio tags: `id3` writes the ID3v2 frames `lofty` reads back, `metaflac` the
|
|
||||||
# Vorbis comment block. The MPEG frames under the first are hand-rolled; the
|
|
||||||
# FLAC stream under the second is committed, because `lofty` reads a real audio
|
|
||||||
# frame to derive the stream's properties and a FLAC frame is CRC-checked
|
|
||||||
# bit-packed data rather than a fixed header. See `tests/corpus/audio.rs`.
|
|
||||||
id3 = "1"
|
|
||||||
metaflac = "0.2"
|
|
||||||
# CRC32 for the hand-rolled stored-entry zip that builds the pptx and ODF
|
|
||||||
# containers. Already in the lockfile transitively via `zip`, so naming it
|
|
||||||
# here compiles nothing new.
|
|
||||||
crc32fast = "1"
|
|
||||||
|
|
||||||
# `harness = false` on both: divan supplies its own `main` via `divan::main()`,
|
# `harness = false` on both: divan supplies its own `main` via `divan::main()`,
|
||||||
# so libtest must not also link one in.
|
# so libtest must not also link one in.
|
||||||
[[bench]]
|
[[bench]]
|
||||||
|
|
|
||||||
|
|
@ -163,37 +163,6 @@ mod substring {
|
||||||
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
|
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `Finder` built once per query against one built per call.
|
|
||||||
///
|
|
||||||
/// **A losing arm, kept as the record.** `memmem::find_iter(hay, needle)`
|
|
||||||
/// constructs a searcher every time, and the full-text pass calls it once
|
|
||||||
/// or twice per candidate row, so hoisting that into the compiled pattern
|
|
||||||
/// looks like free money. It is not: medians of 35.5 ns against 35.2 at
|
|
||||||
/// 1 KiB and 3.18 µs against 3.17 at 256 KiB — indistinguishable at every
|
|
||||||
/// size, including the smallest, where setup would dominate if it were
|
|
||||||
/// going to.
|
|
||||||
///
|
|
||||||
/// The reason is that the precompute is O(needle), and a search term is a
|
|
||||||
/// handful of bytes. What *did* cost something on this path was the
|
|
||||||
/// `to_ascii_lowercase` rebuilding the needle per row, and that is an
|
|
||||||
/// allocation rather than a searcher — see `snippet::extract_folded`, which
|
|
||||||
/// borrows an already-folded term instead.
|
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
|
||||||
fn memmem_per_call_miss(bencher: Bencher, size: usize) {
|
|
||||||
let text = corpus::text(size, 0).as_bytes();
|
|
||||||
bencher.bench(|| {
|
|
||||||
memchr::memmem::find_iter(divan::black_box(text), corpus::NEEDLE.as_bytes()).count()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
|
||||||
fn memmem_per_call_hits(bencher: Bencher, size: usize) {
|
|
||||||
let text = corpus::text(size, 64).as_bytes();
|
|
||||||
bencher.bench(|| {
|
|
||||||
memchr::memmem::find_iter(divan::black_box(text), corpus::NEEDLE.as_bytes()).count()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
#[divan::bench(args = corpus::SIZES)]
|
||||||
fn match_indices_hits(bencher: Bencher, size: usize) {
|
fn match_indices_hits(bencher: Bencher, size: usize) {
|
||||||
let text = corpus::text(size, 64);
|
let text = corpus::text(size, 64);
|
||||||
|
|
@ -207,32 +176,11 @@ mod substring {
|
||||||
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
|
bencher.bench(|| finder.find_iter(divan::black_box(text)).count());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `pass_fulltext` runs per row on the literal path, through the
|
/// What `pass_fulltext` actually runs per row, through the real crate
|
||||||
/// real crate entry points: a case-sensitive count, then one folded
|
/// entry points: a case-sensitive count, then a folded count, then the
|
||||||
/// extraction that yields the count and the snippet together.
|
/// snippet extraction. Three sweeps of the same document.
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
#[divan::bench(args = corpus::SIZES)]
|
||||||
fn cascade_row_sweeps(bencher: Bencher, size: usize) {
|
fn cascade_row_sweeps(bencher: Bencher, size: usize) {
|
||||||
let pattern = literal(corpus::NEEDLE);
|
|
||||||
let text = corpus::text_mixed(size, 4);
|
|
||||||
let folded = corpus::text_folded(size, 4);
|
|
||||||
let opts = snippet::Options { approx_chars: 600 };
|
|
||||||
bencher.bench(|| {
|
|
||||||
let (s, b) = snippet::extract_folded(
|
|
||||||
divan::black_box(text),
|
|
||||||
divan::black_box(folded),
|
|
||||||
&[corpus::NEEDLE],
|
|
||||||
&opts,
|
|
||||||
);
|
|
||||||
let a = pattern.count(text, false);
|
|
||||||
(a, b, s.ranges.len())
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shape it replaced, kept as the comparison: counting the folded
|
|
||||||
/// haystack separately from extracting the window sweeps the same
|
|
||||||
/// document a third time for a number the extraction already knew.
|
|
||||||
#[divan::bench(args = corpus::SIZES)]
|
|
||||||
fn cascade_row_sweeps_separate_count(bencher: Bencher, size: usize) {
|
|
||||||
let pattern = literal(corpus::NEEDLE);
|
let pattern = literal(corpus::NEEDLE);
|
||||||
let text = corpus::text_mixed(size, 4);
|
let text = corpus::text_mixed(size, 4);
|
||||||
let folded = corpus::text_folded(size, 4);
|
let folded = corpus::text_folded(size, 4);
|
||||||
|
|
@ -240,14 +188,15 @@ mod substring {
|
||||||
bencher.bench(|| {
|
bencher.bench(|| {
|
||||||
let a = pattern.count(divan::black_box(text), false);
|
let a = pattern.count(divan::black_box(text), false);
|
||||||
let b = pattern.count_folded(divan::black_box(folded));
|
let b = pattern.count_folded(divan::black_box(folded));
|
||||||
let (s, _) = snippet::extract_folded(text, folded, &[corpus::NEEDLE], &opts);
|
let s = snippet::extract_folded(text, folded, &[corpus::NEEDLE], &opts);
|
||||||
(a, b, s.ranges.len())
|
(a, b, s.ranges.len())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snippet extraction against a pre-folded haystack — on the literal path,
|
/// Snippet extraction against a pre-folded haystack, the third of those
|
||||||
/// now the *only* folded sweep of a row, and the source of its count.
|
/// sweeps. Also carries a per-call `term.to_ascii_lowercase()` at
|
||||||
|
/// `snippet.rs:81` for a needle the caller already holds folded.
|
||||||
mod snippet_extract {
|
mod snippet_extract {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|
@ -376,62 +325,6 @@ mod filename_ladder {
|
||||||
found
|
found
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `find_ascii_ci`'s scalar candidate loop against a `memchr2` one.
|
|
||||||
///
|
|
||||||
/// **A losing arm, kept as the record.** The production function walks the
|
|
||||||
/// haystack a byte at a time comparing `to_ascii_lowercase()`; `memchr2`
|
|
||||||
/// finds the next byte matching either case of the needle's first byte with
|
|
||||||
/// SIMD and only then compares. That looks like it must win, and it does
|
|
||||||
/// not: 47.9 µs against 49.0 µs median, inside the run-to-run spread.
|
|
||||||
///
|
|
||||||
/// Two reasons, both about *short* haystacks. `memchr2` has per-call setup
|
|
||||||
/// to amortize and a filename is tens of bytes, not a document; and the
|
|
||||||
/// scalar loop's inner comparison almost never fires, because a first byte
|
|
||||||
/// that occurs rarely in the corpus makes the loop a plain byte scan the
|
|
||||||
/// compiler already vectorizes.
|
|
||||||
///
|
|
||||||
/// Fold-free and allocation-free either way, so this isolates the search
|
|
||||||
/// itself — unlike the arms above, which conflate it with a fold. If a
|
|
||||||
/// future change makes this pass run over many more rows, re-measure; as it
|
|
||||||
/// stands the SQL `LIKE` prefilter means the classifier barely runs at all
|
|
||||||
/// for literal terms, so this was never where the time was.
|
|
||||||
#[divan::bench]
|
|
||||||
fn find_first_ci_memchr2(bencher: Bencher) {
|
|
||||||
let rows = corpus::rows();
|
|
||||||
let needle = corpus::NEEDLE.as_bytes();
|
|
||||||
// The same shape `TermPattern::find_ascii_ci` would take.
|
|
||||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
|
||||||
let (lo, up) = (
|
|
||||||
needle[0].to_ascii_lowercase(),
|
|
||||||
needle[0].to_ascii_uppercase(),
|
|
||||||
);
|
|
||||||
let last = hay.len().checked_sub(needle.len())?;
|
|
||||||
let mut at = 0usize;
|
|
||||||
while at <= last {
|
|
||||||
let Some(off) = memchr::memchr2(lo, up, &hay[at..=last]) else {
|
|
||||||
return None;
|
|
||||||
};
|
|
||||||
let i = at + off;
|
|
||||||
if hay[i..i + needle.len()].eq_ignore_ascii_case(needle) {
|
|
||||||
return Some(i);
|
|
||||||
}
|
|
||||||
at = i + 1;
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
bencher.bench(|| {
|
|
||||||
let mut found = 0usize;
|
|
||||||
for row in divan::black_box(rows) {
|
|
||||||
if find(row.name.as_bytes(), needle).is_some()
|
|
||||||
|| find(row.path.as_bytes(), needle).is_some()
|
|
||||||
{
|
|
||||||
found += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
found
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bitap, the fuzzy passes' inner loop. Both fuzzy passes are whole-table
|
/// Bitap, the fuzzy passes' inner loop. Both fuzzy passes are whole-table
|
||||||
|
|
|
||||||
|
|
@ -34,98 +34,7 @@
|
||||||
//! progress walk, no size survey — so that every syscall the trace attributes
|
//! progress walk, no size survey — so that every syscall the trace attributes
|
||||||
//! to the tree came from the indexer. The size histogram is printed by `gen`.
|
//! to the tree came from the indexer. The size histogram is printed by `gen`.
|
||||||
|
|
||||||
use std::alloc::{GlobalAlloc, Layout, System};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Allocation accounting
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// `System`, counting. A global allocator is **per binary**, so this affects
|
|
||||||
/// only this probe — the shipped `quicksearch` is untouched.
|
|
||||||
///
|
|
||||||
/// Global atomics rather than the per-thread `Cell`s `tests/search_alloc.rs`
|
|
||||||
/// uses, and for the opposite reason. There the work was synchronous on one
|
|
||||||
/// thread and other *tests* ran concurrently, so per-thread counting was both
|
|
||||||
/// necessary and more precise. Here the work is spread over a walk pool, an
|
|
||||||
/// extraction pool, a feeder and a writer — per-thread counting would report a
|
|
||||||
/// fraction of it — and nothing else is running in this process, so a global
|
|
||||||
/// count is exactly the run.
|
|
||||||
///
|
|
||||||
/// The atomics cost every allocation a contended RMW, which is real overhead
|
|
||||||
/// and shows in the wall-clock line. That is acceptable because both sides of a
|
|
||||||
/// before/after comparison carry the same instrumentation; it is not acceptable
|
|
||||||
/// to quote these timings against numbers from an uninstrumented build.
|
|
||||||
struct Counting;
|
|
||||||
|
|
||||||
static ALLOCS: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static ALLOC_BYTES: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static LIVE: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static PEAK_LIVE: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn note_alloc(size: usize) {
|
|
||||||
ALLOCS.fetch_add(1, Ordering::Relaxed);
|
|
||||||
ALLOC_BYTES.fetch_add(size as u64, Ordering::Relaxed);
|
|
||||||
let live = LIVE.fetch_add(size as u64, Ordering::Relaxed) + size as u64;
|
|
||||||
PEAK_LIVE.fetch_max(live, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl GlobalAlloc for Counting {
|
|
||||||
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
|
|
||||||
let p = unsafe { System.alloc(l) };
|
|
||||||
if !p.is_null() {
|
|
||||||
note_alloc(l.size());
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
|
|
||||||
let p = unsafe { System.alloc_zeroed(l) };
|
|
||||||
if !p.is_null() {
|
|
||||||
note_alloc(l.size());
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
|
|
||||||
LIVE.fetch_sub(l.size() as u64, Ordering::Relaxed);
|
|
||||||
unsafe { System.dealloc(p, l) }
|
|
||||||
}
|
|
||||||
unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
|
|
||||||
let q = unsafe { System.realloc(p, l, new) };
|
|
||||||
if !q.is_null() {
|
|
||||||
let (old, new) = (l.size() as u64, new as u64);
|
|
||||||
ALLOC_BYTES.fetch_add(new.saturating_sub(old), Ordering::Relaxed);
|
|
||||||
let live = if new >= old {
|
|
||||||
LIVE.fetch_add(new - old, Ordering::Relaxed) + (new - old)
|
|
||||||
} else {
|
|
||||||
LIVE.fetch_sub(old - new, Ordering::Relaxed) - (old - new)
|
|
||||||
};
|
|
||||||
PEAK_LIVE.fetch_max(live, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
q
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[global_allocator]
|
|
||||||
static ALLOCATOR: Counting = Counting;
|
|
||||||
|
|
||||||
/// Peak resident set size, from the kernel's own high-water mark. Unlike a
|
|
||||||
/// sampled figure this cannot miss a spike.
|
|
||||||
fn vm_hwm_bytes() -> u64 {
|
|
||||||
std::fs::read_to_string("/proc/self/status")
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| {
|
|
||||||
s.lines()
|
|
||||||
.find(|l| l.starts_with("VmHWM:"))?
|
|
||||||
.split_whitespace()
|
|
||||||
.nth(1)?
|
|
||||||
.parse::<u64>()
|
|
||||||
.ok()
|
|
||||||
})
|
|
||||||
.map(|kib| kib * 1024)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use quicksearch_core::config::Config;
|
use quicksearch_core::config::Config;
|
||||||
|
|
@ -140,24 +49,6 @@ const LARGE_TEXT: usize = 100;
|
||||||
/// the disk. A control group: their cost must not move.
|
/// the disk. A control group: their cost must not move.
|
||||||
const BINARY: usize = 100;
|
const BINARY: usize = 100;
|
||||||
|
|
||||||
/// Scale the generated tree by an integer factor (`QSB_SCALE`), keeping the
|
|
||||||
/// mix between the three groups fixed.
|
|
||||||
///
|
|
||||||
/// The default thousand files is enough to exercise every code path and far
|
|
||||||
/// too few to measure any of them: a run that size is dominated by fixed
|
|
||||||
/// start-up — opening the index, the config reconcile — and its per-file
|
|
||||||
/// figures carry the whole of SQLite's and FTS5's fixed structure spread over
|
|
||||||
/// a thousand rows. Anything claiming to be a per-file cost needs a tree where
|
|
||||||
/// the fixed part has been amortised away, and the difference between two
|
|
||||||
/// scales is the only way to tell the two apart.
|
|
||||||
fn scale() -> usize {
|
|
||||||
std::env::var("QSB_SCALE")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.filter(|n| *n >= 1)
|
|
||||||
.unwrap_or(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const WORDS: &[&str] = &[
|
const WORDS: &[&str] = &[
|
||||||
"alpha",
|
"alpha",
|
||||||
"beta",
|
"beta",
|
||||||
|
|
@ -253,13 +144,11 @@ fn generate(tree: &Path) {
|
||||||
|
|
||||||
let mut rng = Rng(0x5eed);
|
let mut rng = Rng(0x5eed);
|
||||||
let (mut small_bytes, mut large_bytes, mut bin_bytes) = (0usize, 0usize, 0usize);
|
let (mut small_bytes, mut large_bytes, mut bin_bytes) = (0usize, 0usize, 0usize);
|
||||||
let scale = scale();
|
|
||||||
let (small_text, large_text, binary) = (SMALL_TEXT * scale, LARGE_TEXT * scale, BINARY * scale);
|
|
||||||
|
|
||||||
// Spread across subdirectories so the walk does real directory work
|
// Spread across subdirectories so the walk does real directory work
|
||||||
// rather than one enormous readdir.
|
// rather than one enormous readdir.
|
||||||
for i in 0..small_text {
|
for i in 0..SMALL_TEXT {
|
||||||
let dir = tree.join(format!("src/mod{}", i % (40 * scale)));
|
let dir = tree.join(format!("src/mod{}", i % 40));
|
||||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||||
let ext = ["txt", "md", "rs", "json"][i % 4];
|
let ext = ["txt", "md", "rs", "json"][i % 4];
|
||||||
let size = rng.in_range(200, 8 * 1024);
|
let size = rng.in_range(200, 8 * 1024);
|
||||||
|
|
@ -268,8 +157,8 @@ fn generate(tree: &Path) {
|
||||||
std::fs::write(dir.join(format!("f{}.{}", i, ext)), body).expect("write");
|
std::fs::write(dir.join(format!("f{}.{}", i, ext)), body).expect("write");
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in 0..large_text {
|
for i in 0..LARGE_TEXT {
|
||||||
let dir = tree.join(format!("docs/set{}", i % (10 * scale)));
|
let dir = tree.join(format!("docs/set{}", i % 10));
|
||||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||||
let size = rng.in_range(8 * 1024 + 1, 200 * 1024);
|
let size = rng.in_range(8 * 1024 + 1, 200 * 1024);
|
||||||
let body = prose(&mut rng, size);
|
let body = prose(&mut rng, size);
|
||||||
|
|
@ -277,8 +166,8 @@ fn generate(tree: &Path) {
|
||||||
std::fs::write(dir.join(format!("doc{}.md", i)), body).expect("write");
|
std::fs::write(dir.join(format!("doc{}.md", i)), body).expect("write");
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in 0..binary {
|
for i in 0..BINARY {
|
||||||
let dir = tree.join(format!("assets/set{}", i % (10 * scale)));
|
let dir = tree.join(format!("assets/set{}", i % 10));
|
||||||
std::fs::create_dir_all(&dir).expect("mkdir");
|
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||||
let n = rng.in_range(1024, 50 * 1024);
|
let n = rng.in_range(1024, 50 * 1024);
|
||||||
let blob: Vec<u8> = (0..n).map(|_| (rng.next() & 0xff) as u8).collect();
|
let blob: Vec<u8> = (0..n).map(|_| (rng.next() & 0xff) as u8).collect();
|
||||||
|
|
@ -286,21 +175,21 @@ fn generate(tree: &Path) {
|
||||||
std::fs::write(dir.join(format!("blob{}.bin", i)), blob).expect("write");
|
std::fs::write(dir.join(format!("blob{}.bin", i)), blob).expect("write");
|
||||||
}
|
}
|
||||||
|
|
||||||
let total = small_text + large_text + binary;
|
let total = SMALL_TEXT + LARGE_TEXT + BINARY;
|
||||||
eprintln!("generated {} files under {}", total, tree.display());
|
eprintln!("generated {} files under {}", total, tree.display());
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" text <= 8 KiB : {:5} files, {:8.1} MiB (head covers the whole file)",
|
" text <= 8 KiB : {:5} files, {:8.1} MiB (head covers the whole file)",
|
||||||
small_text,
|
SMALL_TEXT,
|
||||||
small_bytes as f64 / (1024.0 * 1024.0)
|
small_bytes as f64 / (1024.0 * 1024.0)
|
||||||
);
|
);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" text > 8 KiB : {:5} files, {:8.1} MiB (extraction must read it)",
|
" text > 8 KiB : {:5} files, {:8.1} MiB (extraction must read it)",
|
||||||
large_text,
|
LARGE_TEXT,
|
||||||
large_bytes as f64 / (1024.0 * 1024.0)
|
large_bytes as f64 / (1024.0 * 1024.0)
|
||||||
);
|
);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
" binary : {:5} files, {:8.1} MiB (no extractor; control group)",
|
" binary : {:5} files, {:8.1} MiB (no extractor; control group)",
|
||||||
binary,
|
BINARY,
|
||||||
bin_bytes as f64 / (1024.0 * 1024.0)
|
bin_bytes as f64 / (1024.0 * 1024.0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -319,135 +208,6 @@ fn prose(rng: &mut Rng, target: usize) -> String {
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The kernel's own accounting for this process, from `/proc/self/io`.
|
|
||||||
///
|
|
||||||
/// `read_bytes`/`write_bytes` are what actually reached the block layer, so
|
|
||||||
/// they are the figures that describe the *disk* rather than the page cache —
|
|
||||||
/// a warm re-read shows as `rchar` without moving `read_bytes`. `syscr`/`syscw`
|
|
||||||
/// count the calls regardless, which is what separates "we read a lot" from
|
|
||||||
/// "we read a little, many times".
|
|
||||||
///
|
|
||||||
/// Zero everywhere on a filesystem that does not report it (virtiofs, some
|
|
||||||
/// network mounts); the caller says so rather than printing a confident 0.
|
|
||||||
#[derive(Default, Clone, Copy)]
|
|
||||||
struct Io {
|
|
||||||
rchar: u64,
|
|
||||||
wchar: u64,
|
|
||||||
syscr: u64,
|
|
||||||
syscw: u64,
|
|
||||||
read_bytes: u64,
|
|
||||||
write_bytes: u64,
|
|
||||||
cancelled: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Io {
|
|
||||||
fn read() -> Io {
|
|
||||||
let mut io = Io::default();
|
|
||||||
let Ok(text) = std::fs::read_to_string("/proc/self/io") else {
|
|
||||||
return io;
|
|
||||||
};
|
|
||||||
for line in text.lines() {
|
|
||||||
let Some((key, value)) = line.split_once(':') else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(value) = value.trim().parse::<u64>() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
match key {
|
|
||||||
"rchar" => io.rchar = value,
|
|
||||||
"wchar" => io.wchar = value,
|
|
||||||
"syscr" => io.syscr = value,
|
|
||||||
"syscw" => io.syscw = value,
|
|
||||||
"read_bytes" => io.read_bytes = value,
|
|
||||||
"write_bytes" => io.write_bytes = value,
|
|
||||||
"cancelled_write_bytes" => io.cancelled = value,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
io
|
|
||||||
}
|
|
||||||
|
|
||||||
fn since(&self, start: &Io) -> Io {
|
|
||||||
Io {
|
|
||||||
rchar: self.rchar.saturating_sub(start.rchar),
|
|
||||||
wchar: self.wchar.saturating_sub(start.wchar),
|
|
||||||
syscr: self.syscr.saturating_sub(start.syscr),
|
|
||||||
syscw: self.syscw.saturating_sub(start.syscw),
|
|
||||||
read_bytes: self.read_bytes.saturating_sub(start.read_bytes),
|
|
||||||
write_bytes: self.write_bytes.saturating_sub(start.write_bytes),
|
|
||||||
cancelled: self.cancelled.saturating_sub(start.cancelled),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mib(bytes: u64) -> String {
|
|
||||||
format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What the write-ahead log did during a run, sampled from outside the process.
|
|
||||||
///
|
|
||||||
/// The interesting part of write amplification is not the total — that is one
|
|
||||||
/// number from `/proc/self/io` — but how it splits between **frames appended to
|
|
||||||
/// the log** and **pages copied back into the database** by a checkpoint. The
|
|
||||||
/// two want opposite fixes: more frames means the load is rewriting pages, and
|
|
||||||
/// more copy-back means it is checkpointing too often. A page rewritten five
|
|
||||||
/// times between two checkpoints costs five frames and *one* copy-back, so
|
|
||||||
/// checkpointing less often can be strictly cheaper — which is the opposite of
|
|
||||||
/// what "keep the log small" suggests.
|
|
||||||
///
|
|
||||||
/// Sampled rather than instrumented: the log is a file, its size is a `stat`,
|
|
||||||
/// and a checkpoint truncates it. Growth between samples is frames appended; a
|
|
||||||
/// drop is a checkpoint, and the size it dropped *from* bounds what that
|
|
||||||
/// checkpoint copied. Nothing in the library has to know it is being watched.
|
|
||||||
#[derive(Default, Clone, Copy)]
|
|
||||||
struct WalStats {
|
|
||||||
/// Largest the log ever got.
|
|
||||||
peak: u64,
|
|
||||||
/// Sum of every increase — bytes appended to the log over the run.
|
|
||||||
appended: u64,
|
|
||||||
/// Sum of the size before each truncation — an upper bound on the bytes
|
|
||||||
/// each checkpoint wrote back into the database.
|
|
||||||
copied_back: u64,
|
|
||||||
checkpoints: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Watch `path` until `stop` is set, at `SAMPLE`.
|
|
||||||
///
|
|
||||||
/// One millisecond, because a checkpoint of a small log is quick and a sampler
|
|
||||||
/// that misses the rise and the fall reports neither. It costs one `stat` per
|
|
||||||
/// millisecond, which is nothing next to what is being measured.
|
|
||||||
fn sample_wal(path: PathBuf, stop: std::sync::Arc<std::sync::atomic::AtomicBool>) -> std::thread::JoinHandle<WalStats> {
|
|
||||||
const SAMPLE: Duration = Duration::from_millis(1);
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
let mut stats = WalStats::default();
|
|
||||||
let mut last = 0u64;
|
|
||||||
while !stop.load(Ordering::Relaxed) {
|
|
||||||
let now = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
|
||||||
if now > last {
|
|
||||||
stats.appended += now - last;
|
|
||||||
} else if now < last {
|
|
||||||
// A shrink is a checkpoint landing the log. `last` is the most
|
|
||||||
// recent size seen before it, so it bounds the copy-back.
|
|
||||||
stats.checkpoints += 1;
|
|
||||||
stats.copied_back += last;
|
|
||||||
}
|
|
||||||
stats.peak = stats.peak.max(now);
|
|
||||||
last = now;
|
|
||||||
std::thread::sleep(SAMPLE);
|
|
||||||
}
|
|
||||||
stats
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Size of the index and the sidecars it leaves behind.
|
|
||||||
fn db_sizes(db: &Path) -> (u64, u64) {
|
|
||||||
let len = |p: PathBuf| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
|
|
||||||
(
|
|
||||||
len(db.to_path_buf()),
|
|
||||||
len(PathBuf::from(format!("{}-wal", db.display()))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(mode: &str, tree: &Path, db: &Path) {
|
fn run(mode: &str, tree: &Path, db: &Path) {
|
||||||
let config = Config::default();
|
let config = Config::default();
|
||||||
|
|
||||||
|
|
@ -461,15 +221,6 @@ fn run(mode: &str, tree: &Path, db: &Path) {
|
||||||
.expect("clear marker");
|
.expect("clear marker");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleared so the phase summaries below belong to this run alone.
|
|
||||||
quicksearch_core::log::clear();
|
|
||||||
let io_start = Io::read();
|
|
||||||
let (db_before, wal_before) = db_sizes(db);
|
|
||||||
|
|
||||||
let wal_path = PathBuf::from(format!("{}-wal", db.display()));
|
|
||||||
let wal_stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let wal_sampler = sample_wal(wal_path, wal_stop.clone());
|
|
||||||
|
|
||||||
let service = IndexingService::new();
|
let service = IndexingService::new();
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
service
|
service
|
||||||
|
|
@ -498,10 +249,7 @@ fn run(mode: &str, tree: &Path, db: &Path) {
|
||||||
}
|
}
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
assert!(done, "indexing did not finish within the timeout");
|
assert!(done, "indexing did not finish within the timeout");
|
||||||
// The run's last checkpoint happens inside here, so the sampler outlives it.
|
|
||||||
service.stop_indexing().expect("stop");
|
service.stop_indexing().expect("stop");
|
||||||
wal_stop.store(true, Ordering::Relaxed);
|
|
||||||
let wal = wal_sampler.join().unwrap_or_default();
|
|
||||||
|
|
||||||
// Count what was actually indexed rather than assuming `gen`'s tree.
|
// Count what was actually indexed rather than assuming `gen`'s tree.
|
||||||
// The constants describe the tree this probe builds; pointing it at any
|
// The constants describe the tree this probe builds; pointing it at any
|
||||||
|
|
@ -510,88 +258,11 @@ fn run(mode: &str, tree: &Path, db: &Path) {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|c| quicksearch_core::db::repo::row_count(&c).ok())
|
.and_then(|c| quicksearch_core::db::repo::row_count(&c).ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
// Read after `stop_indexing`, so the optimize pass's checkpoint — which is
|
|
||||||
// where a run's dirty pages actually reach the file — is inside the totals.
|
|
||||||
let io = Io::read().since(&io_start);
|
|
||||||
let (db_after, wal_after) = db_sizes(db);
|
|
||||||
let per_file = |n: u64| {
|
|
||||||
if total == 0 {
|
|
||||||
"-".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{:.0} B/file", n as f64 / total as f64)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"\n{}: {:?} ({:.0} files/sec over {} files)",
|
"{}: {:?} ({:.0} files/sec over {} files)",
|
||||||
mode,
|
mode,
|
||||||
elapsed,
|
elapsed,
|
||||||
total as f64 / elapsed.as_secs_f64(),
|
total as f64 / elapsed.as_secs_f64(),
|
||||||
total
|
total
|
||||||
);
|
);
|
||||||
|
|
||||||
// The pipeline logs one line per root per phase; they are the walk/extract
|
|
||||||
// split without a `perf` session.
|
|
||||||
for line in quicksearch_core::log::snapshot() {
|
|
||||||
let m = &line.text;
|
|
||||||
if m.contains("walk done")
|
|
||||||
|| m.contains("walk ended early")
|
|
||||||
|| m.contains("content done")
|
|
||||||
|| m.contains("stale cleanup")
|
|
||||||
|| m.contains("indexing complete")
|
|
||||||
{
|
|
||||||
eprintln!(" phase {}", m);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let allocs = ALLOCS.load(Ordering::Relaxed);
|
|
||||||
eprintln!(
|
|
||||||
" wal peak {}, {} appended, {} copied back over {} checkpoint(s)",
|
|
||||||
mib(wal.peak),
|
|
||||||
mib(wal.appended),
|
|
||||||
mib(wal.copied_back),
|
|
||||||
wal.checkpoints,
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" memory {} allocations ({:.1} per file), {} churned, peak live {}, VmHWM {}",
|
|
||||||
allocs,
|
|
||||||
allocs as f64 / total.max(1) as f64,
|
|
||||||
mib(ALLOC_BYTES.load(Ordering::Relaxed)),
|
|
||||||
mib(PEAK_LIVE.load(Ordering::Relaxed)),
|
|
||||||
mib(vm_hwm_bytes()),
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" index {} -> {} wal {} -> {}",
|
|
||||||
mib(db_before),
|
|
||||||
mib(db_after),
|
|
||||||
mib(wal_before),
|
|
||||||
mib(wal_after),
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" syscall {} reads, {} writes ({:.1} reads/file, {:.1} writes/file)",
|
|
||||||
io.syscr,
|
|
||||||
io.syscw,
|
|
||||||
io.syscr as f64 / total.max(1) as f64,
|
|
||||||
io.syscw as f64 / total.max(1) as f64,
|
|
||||||
);
|
|
||||||
eprintln!(
|
|
||||||
" bytes rchar {} / wchar {} (through the syscall layer, cache included)",
|
|
||||||
mib(io.rchar),
|
|
||||||
mib(io.wchar),
|
|
||||||
);
|
|
||||||
if io.read_bytes == 0 && io.write_bytes == 0 {
|
|
||||||
eprintln!(
|
|
||||||
" disk not reported for this filesystem (virtiofs/tmpfs); \
|
|
||||||
use rchar/wchar and the index sizes above"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
" disk read {} / written {} (cancelled {}) -> {} written",
|
|
||||||
mib(io.read_bytes),
|
|
||||||
mib(io.write_bytes),
|
|
||||||
mib(io.cancelled),
|
|
||||||
per_file(io.write_bytes.saturating_sub(io.cancelled)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
//! Search latency against an index that already exists on disk.
|
|
||||||
//!
|
|
||||||
//! The counterweight to `indexprobe`. Every FTS write-side knob — `automerge`,
|
|
||||||
//! a final `'optimize'`, `pgsz` — buys indexing time by leaving more segments
|
|
||||||
//! behind, and a segment is a b-tree a query has to visit. Halving a cold index
|
|
||||||
//! while doubling a keystroke is a regression wearing an improvement's clothes,
|
|
||||||
//! and this is what says which one happened.
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cargo build -p quicksearch-core --example searchtime --release
|
|
||||||
//! ./target/release/examples/searchtime /path/to/index.db
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Queries are run against one held connection, as the search worker holds one
|
|
||||||
//! across a typing session, and each is timed best-of-N so a single scheduling
|
|
||||||
//! hiccup does not become the headline.
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use quicksearch_core::query::split::split_for_cascade;
|
|
||||||
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
|
||||||
|
|
||||||
/// `(read_bytes, rchar)` from `/proc/self/io`: what reached the block layer,
|
|
||||||
/// and what passed through the read syscalls. A query whose time varies while
|
|
||||||
/// `rchar` does not is not doing more work — it is waiting on the disk.
|
|
||||||
fn proc_io() -> (u64, u64) {
|
|
||||||
let text = std::fs::read_to_string("/proc/self/io").unwrap_or_default();
|
|
||||||
let field = |key: &str| -> u64 {
|
|
||||||
text.lines()
|
|
||||||
.find_map(|l| l.strip_prefix(key)?.trim().trim_start_matches(':').trim().parse().ok())
|
|
||||||
.unwrap_or(0)
|
|
||||||
};
|
|
||||||
(field("read_bytes"), field("rchar"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs per query; the best is reported.
|
|
||||||
const RUNS: u32 = 5;
|
|
||||||
|
|
||||||
/// The query set, chosen to reach the passes an FTS setting can affect.
|
|
||||||
///
|
|
||||||
/// The content queries are the point — they are the ones that go through
|
|
||||||
/// `searchabletext` and therefore through however many segments the write side
|
|
||||||
/// left behind. The filename query is the control: it never touches FTS, so it
|
|
||||||
/// must not move.
|
|
||||||
const QUERIES: &[(&str, bool, &str)] = &[
|
|
||||||
("filename (control)", false, "doc42"),
|
|
||||||
("content, common", false, "mountain"),
|
|
||||||
("content, rare", false, "quartzite"),
|
|
||||||
("content, two words", false, "ocean forest"),
|
|
||||||
("fuzzy content", true, "mountian"),
|
|
||||||
];
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let db = PathBuf::from(
|
|
||||||
std::env::args()
|
|
||||||
.nth(1)
|
|
||||||
.expect("usage: searchtime <index.db>"),
|
|
||||||
);
|
|
||||||
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
|
|
||||||
.expect("open the index");
|
|
||||||
// Override the profile's ceiling, to test whether a slow index is slow
|
|
||||||
// because its working set does not fit rather than because it is bigger.
|
|
||||||
if let Ok(kib) = std::env::var("QSB_CACHE_KIB") {
|
|
||||||
conn.execute_batch(&format!("PRAGMA cache_size = -{};", kib.trim()))
|
|
||||||
.expect("set cache_size");
|
|
||||||
}
|
|
||||||
|
|
||||||
let segments: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM searchabletext_idx", [], |r| r.get(0))
|
|
||||||
.unwrap_or(-1);
|
|
||||||
let rows: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0))
|
|
||||||
.unwrap_or(-1);
|
|
||||||
println!(
|
|
||||||
"{} ({} rows, {} segment-index entries)",
|
|
||||||
db.display(),
|
|
||||||
rows,
|
|
||||||
segments
|
|
||||||
);
|
|
||||||
println!("{:<22} {:>10} {:>8}", "query", "best", "hits");
|
|
||||||
|
|
||||||
let mut total = Duration::ZERO;
|
|
||||||
for (label, fuzzy, query) in QUERIES {
|
|
||||||
let split = split_for_cascade(query).expect("query parses");
|
|
||||||
// The display limit makes this benchmark unfair between indexes.
|
|
||||||
// `scan_pass` stops as soon as the limit is full, and it streams FTS
|
|
||||||
// candidates in rowid order — which is `file_id` order, which is the
|
|
||||||
// order the *walk* happened to insert rows. So an index where the large
|
|
||||||
// documents drew low ids decompresses megabytes to fill 1000 hits while
|
|
||||||
// one where the small documents did reads a few hundred kilobytes, and
|
|
||||||
// the two differ by 13x for reasons that have nothing to do with what is
|
|
||||||
// being compared. Measured: 2.4 MiB against 110.9 MiB of `rchar` for the
|
|
||||||
// same query and the same hit count.
|
|
||||||
//
|
|
||||||
// Raising the limit past the corpus makes every index examine every
|
|
||||||
// candidate, which is the only way two of them are doing equal work.
|
|
||||||
let limit = std::env::var("QSB_LIMIT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(1000);
|
|
||||||
let options = SearchOptions {
|
|
||||||
fuzzy: *fuzzy,
|
|
||||||
limit,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let mut best = Duration::MAX;
|
|
||||||
let mut hits = 0usize;
|
|
||||||
let io_before = proc_io();
|
|
||||||
for _ in 0..RUNS {
|
|
||||||
let latest = AtomicU64::new(1);
|
|
||||||
let mut count = 0usize;
|
|
||||||
let mut sink = |h: Vec<SearchHit>| count += h.len();
|
|
||||||
let start = Instant::now();
|
|
||||||
cascade::run(&conn, &split, &options, 1, &latest, &mut sink).expect("cascade runs");
|
|
||||||
best = best.min(start.elapsed());
|
|
||||||
hits = count;
|
|
||||||
}
|
|
||||||
total += best;
|
|
||||||
let (rd, rc) = proc_io();
|
|
||||||
let (rd0, rc0) = io_before;
|
|
||||||
println!(
|
|
||||||
"{:<22} {:>10.1?} {:>8} disk-read {:>8.1} MiB rchar {:>8.1} MiB (over {} runs)",
|
|
||||||
label,
|
|
||||||
best,
|
|
||||||
hits,
|
|
||||||
(rd - rd0) as f64 / 1048576.0,
|
|
||||||
(rc - rc0) as f64 / 1048576.0,
|
|
||||||
RUNS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!("{:<22} {:>10.1?}", "TOTAL", total);
|
|
||||||
}
|
|
||||||
|
|
@ -80,7 +80,7 @@ fn serial(root: &str, config: &Config, existing: &DirRows) -> (usize, usize) {
|
||||||
for entry in filtered_walk(root, false, false, &ignore, &UnreadableDirs::default()) {
|
for entry in filtered_walk(root, false, false, &ignore, &UnreadableDirs::default()) {
|
||||||
seen += 1;
|
seen += 1;
|
||||||
// Same rule as the real walk: a name that is not valid UTF-8 cannot be
|
// Same rule as the real walk: a name that is not valid UTF-8 cannot be
|
||||||
// stored in the index and reopened by its path, so it is skipped before
|
// stored in `files.path` and reopened by it, so it is skipped before
|
||||||
// anything tries to hash it. Counted as seen, never prepared.
|
// anything tries to hash it. Counted as seen, never prepared.
|
||||||
let Some(path) = entry.path().to_str().map(str::to_owned) else {
|
let Some(path) = entry.path().to_str().map(str::to_owned) else {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -103,23 +103,17 @@ impl SizeReport {
|
||||||
/// failure reason comes from `failed_files`, the one place it is written.
|
/// failure reason comes from `failed_files`, the one place it is written.
|
||||||
pub fn status_for_path(db_path: &str, path: &str) -> Result<FileStatus, String> {
|
pub fn status_for_path(db_path: &str, path: &str) -> Result<FileStatus, String> {
|
||||||
let conn = open_existing(db_path, false)?;
|
let conn = open_existing(db_path, false)?;
|
||||||
let split = crate::file_handling::split_db_path(path);
|
let row: Option<(i64, Option<String>)> = conn
|
||||||
let row: Option<(i64, Option<String>)> = match split {
|
.query_row(
|
||||||
// Not a path a row could ever have been stored under, so it is not
|
"SELECT f.content_state, ff.reason \
|
||||||
// indexed — the same answer as a miss.
|
|
||||||
None => None,
|
|
||||||
Some((parent, name)) => conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT f.content_state, ff.reason \
|
|
||||||
FROM files f \
|
FROM files f \
|
||||||
LEFT JOIN failed_files ff ON ff.file_id = f.id \
|
LEFT JOIN failed_files ff ON ff.file_id = f.id \
|
||||||
WHERE f.parent = ?1 AND f.name = ?2",
|
WHERE f.path = ?1",
|
||||||
params![parent, name],
|
params![path],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| format!("status_for_path({}): {}", path, e))?,
|
.map_err(|e| format!("status_for_path({}): {}", path, e))?;
|
||||||
};
|
|
||||||
Ok(match row {
|
Ok(match row {
|
||||||
None => FileStatus {
|
None => FileStatus {
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
|
|
@ -144,7 +138,7 @@ pub fn list_failed(db_path: &str, limit: Option<u32>) -> Result<Vec<FailedEntry>
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT ff.file_id, f.parent, f.name, ff.reason, ff.ts \
|
"SELECT ff.file_id, f.path, ff.reason, ff.ts \
|
||||||
FROM failed_files ff \
|
FROM failed_files ff \
|
||||||
JOIN files f ON f.id = ff.file_id \
|
JOIN files f ON f.id = ff.file_id \
|
||||||
ORDER BY ff.ts DESC{}",
|
ORDER BY ff.ts DESC{}",
|
||||||
|
|
@ -155,13 +149,11 @@ pub fn list_failed(db_path: &str, limit: Option<u32>) -> Result<Vec<FailedEntry>
|
||||||
.map_err(|e| format!("list_failed prepare: {}", e))?;
|
.map_err(|e| format!("list_failed prepare: {}", e))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map([], |r| {
|
.query_map([], |r| {
|
||||||
let parent: String = r.get(1)?;
|
|
||||||
let name: String = r.get(2)?;
|
|
||||||
Ok(FailedEntry {
|
Ok(FailedEntry {
|
||||||
file_id: r.get(0)?,
|
file_id: r.get(0)?,
|
||||||
path: format!("{}{}", parent, name),
|
path: r.get(1)?,
|
||||||
reason: r.get(3)?,
|
reason: r.get(2)?,
|
||||||
ts: r.get(4)?,
|
ts: r.get(3)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("list_failed query: {}", e))?;
|
.map_err(|e| format!("list_failed query: {}", e))?;
|
||||||
|
|
@ -260,7 +252,8 @@ mod tests {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "a.txt",
|
name: "a.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/a.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -276,7 +269,8 @@ mod tests {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "b.bin",
|
name: "b.bin",
|
||||||
parent: "/tmp/",
|
path: "/tmp/b.bin",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -349,7 +343,8 @@ mod tests {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "c.txt",
|
name: "c.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/c.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -366,7 +361,8 @@ mod tests {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "d.bin",
|
name: "d.bin",
|
||||||
parent: "/tmp/",
|
path: "/tmp/d.bin",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -426,7 +422,8 @@ mod tests {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "big.txt",
|
name: "big.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/big.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -480,12 +477,13 @@ mod tests {
|
||||||
{
|
{
|
||||||
let mut conn = open_or_recreate(dbp, "unicode61").unwrap();
|
let mut conn = open_or_recreate(dbp, "unicode61").unwrap();
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
for name in ["a.txt", "b.txt"] {
|
for (name, path) in [("a.txt", "/tmp/a.txt"), ("b.txt", "/tmp/b.txt")] {
|
||||||
insert_file(
|
insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name,
|
name,
|
||||||
parent: "/tmp/",
|
path,
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use super::*;
|
||||||
/// app was closed. See [`crate::scope`] for the pass that applies it.
|
/// app was closed. See [`crate::scope`] for the pass that applies it.
|
||||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||||
pub struct IndexWork {
|
pub struct IndexWork {
|
||||||
/// Roots that are no longer configured, in stored spelling. Every
|
/// Roots that are no longer configured, in `files.path` spelling. Every
|
||||||
/// row beneath one is deleted; no filesystem access is involved, so a
|
/// row beneath one is deleted; no filesystem access is involved, so a
|
||||||
/// root whose folder is gone is handled the same as one that still
|
/// root whose folder is gone is handled the same as one that still
|
||||||
/// exists.
|
/// exists.
|
||||||
|
|
|
||||||
|
|
@ -535,13 +535,6 @@ impl Config {
|
||||||
let mut cfg: Config = toml::from_str(&content)
|
let mut cfg: Config = toml::from_str(&content)
|
||||||
.map_err(|e| format!("Failed to parse config file {}: {}", path.display(), e))?;
|
.map_err(|e| format!("Failed to parse config file {}: {}", path.display(), e))?;
|
||||||
cfg.source = Some(path.to_path_buf());
|
cfg.source = Some(path.to_path_buf());
|
||||||
// Before anything reads a value — and in particular before
|
|
||||||
// `config_check` compares `hash_length` against what the index was
|
|
||||||
// built with, or a clamp applied later would read as a changed
|
|
||||||
// setting and force a rebuild.
|
|
||||||
for warning in cfg.clamp_out_of_range() {
|
|
||||||
crate::log_warn!("config: {}", warning);
|
|
||||||
}
|
|
||||||
Ok(cfg)
|
Ok(cfg)
|
||||||
} else {
|
} else {
|
||||||
let cfg = Config {
|
let cfg = Config {
|
||||||
|
|
@ -553,70 +546,6 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bring values that would break the program back into range, returning a
|
|
||||||
/// line about each one changed.
|
|
||||||
///
|
|
||||||
/// Clamps, never rejects: this file is hand-editable and a typo in it must
|
|
||||||
/// not stop the app starting, the same position `main.rs` takes on a file
|
|
||||||
/// that will not parse at all. Only the fields that are *not* already
|
|
||||||
/// defended where they are used appear here — `results_per_page`,
|
|
||||||
/// `root_workers`, `ui.scale`, `maximum_wal_size`, `batch_size` and
|
|
||||||
/// `reindex_interval_minutes` all clamp at their call sites, and doing it
|
|
||||||
/// twice would just be two places to disagree.
|
|
||||||
fn clamp_out_of_range(&mut self) -> Vec<String> {
|
|
||||||
let mut warnings = Vec::new();
|
|
||||||
let mut clamp = |name: &str, value: &mut u64, lo: u64, hi: u64| {
|
|
||||||
let bounded = (*value).clamp(lo, hi);
|
|
||||||
if bounded != *value {
|
|
||||||
warnings.push(format!(
|
|
||||||
"{} is {}, which is out of range; using {}",
|
|
||||||
name, *value, bounded
|
|
||||||
));
|
|
||||||
*value = bounded;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Zero means every search returns nothing at all: `cascade::run`
|
|
||||||
// computes `remaining()` as zero and stops before its first pass, and
|
|
||||||
// reports the empty result as truncated.
|
|
||||||
let mut display_limit = self.search.display_limit as u64;
|
|
||||||
clamp("[search] display_limit", &mut display_limit, 1, 1_000_000);
|
|
||||||
self.search.display_limit = display_limit as usize;
|
|
||||||
|
|
||||||
// The last ceiling on how much an extractor reads: the extractors cap
|
|
||||||
// a single read, but this is what decides which files they open at all.
|
|
||||||
clamp(
|
|
||||||
"[processing] maximum_text_file_size",
|
|
||||||
&mut self.processing.maximum_text_file_size,
|
|
||||||
1,
|
|
||||||
4 * 1024 * 1024 * 1024,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Below 262 bytes `infer`'s longest magic-number matcher cannot run,
|
|
||||||
// so file types stop being detectable by content; above a megabyte the
|
|
||||||
// walk's per-file head buffer stops being a head.
|
|
||||||
let mut hash_length = self.processing.hash_length as u64;
|
|
||||||
clamp(
|
|
||||||
"[processing] hash_length",
|
|
||||||
&mut hash_length,
|
|
||||||
262,
|
|
||||||
1024 * 1024,
|
|
||||||
);
|
|
||||||
self.processing.hash_length = hash_length as usize;
|
|
||||||
|
|
||||||
// The writer's round-robin turn. Unbounded, one root holds the writer
|
|
||||||
// for as long as it likes and every other root's walk waits behind it;
|
|
||||||
// zero is meaningful (one quantum per turn) and stays legal.
|
|
||||||
clamp(
|
|
||||||
"[processing] writer_turn_slice_ms",
|
|
||||||
&mut self.processing.writer_turn_slice_ms,
|
|
||||||
0,
|
|
||||||
10_000,
|
|
||||||
);
|
|
||||||
|
|
||||||
warnings
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write back to the file this config was loaded from (or the default
|
/// Write back to the file this config was loaded from (or the default
|
||||||
/// location), creating parent directories as needed. Raw values are
|
/// location), creating parent directories as needed. Raw values are
|
||||||
/// written verbatim — relative paths in a portable config stay relative.
|
/// written verbatim — relative paths in a portable config stay relative.
|
||||||
|
|
@ -638,37 +567,13 @@ impl Config {
|
||||||
// open again. `rename` is atomic on both platforms, and the `sync_all`
|
// open again. `rename` is atomic on both platforms, and the `sync_all`
|
||||||
// before it means the bytes are on the disk before the name points at
|
// before it means the bytes are on the disk before the name points at
|
||||||
// them.
|
// them.
|
||||||
let tmp = write_private_temp(&path, content.as_bytes()).map_err(|e| {
|
let tmp = path.with_extension("toml.tmp");
|
||||||
format!(
|
write_private(&tmp, content.as_bytes())
|
||||||
"Failed to write config file beside {}: {}",
|
.map_err(|e| format!("Failed to write config file {}: {}", tmp.display(), e))?;
|
||||||
path.display(),
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
fs::rename(&tmp, &path).map_err(|e| {
|
fs::rename(&tmp, &path).map_err(|e| {
|
||||||
let _ = fs::remove_file(&tmp);
|
let _ = fs::remove_file(&tmp);
|
||||||
format!("Failed to replace config file {}: {}", path.display(), e)
|
format!("Failed to replace config file {}: {}", path.display(), e)
|
||||||
})?;
|
})?;
|
||||||
// The rename is atomic but not durable: it is a directory entry, and
|
|
||||||
// the directory has its own dirty state. Without this a power cut just
|
|
||||||
// after can leave *neither* name, which is the salt loss the whole
|
|
||||||
// dance exists to prevent. Best-effort, and on Windows it is a no-op
|
|
||||||
// every time rather than only on an exotic filesystem: opening a
|
|
||||||
// directory as a file needs `FILE_FLAG_BACKUP_SEMANTICS`, which
|
|
||||||
// `fs::File::open` does not pass, so this fails and is skipped. NTFS
|
|
||||||
// journals the rename itself, which is the guarantee this is reaching
|
|
||||||
// for; on Unix it has to be asked for.
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
if let Ok(handle) = fs::File::open(dir) {
|
|
||||||
let _ = handle.sync_all();
|
|
||||||
}
|
|
||||||
// Leftovers from a save that died between `create_new` and the
|
|
||||||
// rename. The old fixed `config.toml.tmp` overwrote itself, so
|
|
||||||
// there was never more than one; a unique name per attempt is what
|
|
||||||
// makes the write safe (see `write_private_temp`) and what makes
|
|
||||||
// them accumulate, so they are swept here instead.
|
|
||||||
sweep_stale_temps(dir, &path);
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -693,72 +598,8 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `path` is the index itself — the database, one of SQLite's
|
|
||||||
/// `-wal`/`-shm`/`-journal` sidecars, or the instance lock.
|
|
||||||
///
|
|
||||||
/// For the paths that arrive from a `files` row rather than from a walk:
|
|
||||||
/// once the walk stops indexing the index
|
|
||||||
/// ([`crate::file_handling::index_file_set`], which explains why opening
|
|
||||||
/// one is fatal), such rows are swept away, but a row written by an older
|
|
||||||
/// build survives until the sweep reaches it and can still be opened from
|
|
||||||
/// a result list in the meantime.
|
|
||||||
///
|
|
||||||
/// The name is compared first and the directory only on a hit, so the
|
|
||||||
/// overwhelmingly common miss costs one string comparison rather than the
|
|
||||||
/// `canonicalize` the directory check needs.
|
|
||||||
///
|
|
||||||
/// Names are folded where the filesystem folds them
|
|
||||||
/// ([`crate::platform::PATHS_ARE_CASE_INSENSITIVE`]): a stored row spelling
|
|
||||||
/// the database `Index.sqlite` names the same file as a config spelling it
|
|
||||||
/// `index.sqlite`, and opening it has the same consequence. ASCII only,
|
|
||||||
/// matching `PATH_COLLATION` and [`IgnoreSet`].
|
|
||||||
pub fn is_index_file(&self, path: &Path) -> bool {
|
|
||||||
let db = self.resolved_database_path();
|
|
||||||
let (Some(db_name), Some(name)) = (
|
|
||||||
db.file_name().and_then(|s| s.to_str()),
|
|
||||||
path.file_name().and_then(|s| s.to_str()),
|
|
||||||
) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
let same_name = |a: &str, b: &str| {
|
|
||||||
if crate::platform::PATHS_ARE_CASE_INSENSITIVE {
|
|
||||||
a.eq_ignore_ascii_case(b)
|
|
||||||
} else {
|
|
||||||
a == b
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// `str::get`, not `name[..cut]`: the cut is `db_name`'s *byte* length
|
|
||||||
// and the two names are unrelated strings, so it can land inside a
|
|
||||||
// multi-byte character — `€xyz` against a two-byte `db_name` is six
|
|
||||||
// bytes either way — and indexing there panics. A non-boundary is
|
|
||||||
// simply not a match.
|
|
||||||
let name_matches = same_name(name, db_name)
|
|
||||||
|| crate::file_handling::INDEX_SIDECAR_SUFFIXES
|
|
||||||
.iter()
|
|
||||||
.any(|s| {
|
|
||||||
let cut = db_name.len();
|
|
||||||
name.len() == cut + s.len()
|
|
||||||
&& name.get(..cut).is_some_and(|head| same_name(head, db_name))
|
|
||||||
&& name.get(cut..).is_some_and(|tail| same_name(tail, s))
|
|
||||||
});
|
|
||||||
if !name_matches {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let same_dir = |a: &Path, b: &Path| {
|
|
||||||
a == b
|
|
||||||
|| a.canonicalize().unwrap_or_else(|_| a.to_path_buf())
|
|
||||||
== b.canonicalize().unwrap_or_else(|_| b.to_path_buf())
|
|
||||||
};
|
|
||||||
match (path.parent(), db.parent()) {
|
|
||||||
(Some(a), Some(b)) => same_dir(a, b),
|
|
||||||
// Both at a filesystem root, or neither: the name match stands.
|
|
||||||
(None, None) => true,
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `resolved_indexing_paths` canonicalized and spelled the way
|
/// `resolved_indexing_paths` canonicalized and spelled the way
|
||||||
/// stored parents are prefixed with them.
|
/// `files.path` prefixes them.
|
||||||
///
|
///
|
||||||
/// The form roots must be compared in: `~/docs`, `docs` in a portable
|
/// The form roots must be compared in: `~/docs`, `docs` in a portable
|
||||||
/// config and `/home/me/docs` are one root under three spellings, and a
|
/// config and `/home/me/docs` are one root under three spellings, and a
|
||||||
|
|
@ -790,121 +631,30 @@ impl Config {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write `bytes` to a fresh temporary file beside `target`, owner-only, with
|
/// Write `bytes` to `path`, owner-readable only, and flush them to the disk
|
||||||
/// its contents flushed to the disk, and return its path for the caller to
|
/// before returning.
|
||||||
/// rename into place.
|
|
||||||
///
|
///
|
||||||
/// `create_new` and a unique name, not a fixed one: `O_NOFOLLOW` refuses a
|
/// `O_NOFOLLOW` on Unix: the config directory is not always somewhere only
|
||||||
/// symlink but says nothing about a *regular* file or a hardlink that is
|
/// this user can write — a portable install can sit in a shared or removable
|
||||||
/// already sitting at the name we were going to use. The config directory is
|
/// directory — and a symlink left at the config's name would otherwise
|
||||||
/// not always somewhere only this user can write — a portable install can sit
|
/// redirect this write onto whatever it points at.
|
||||||
/// in a shared or removable directory — and an attacker who pre-creates the
|
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||||
/// temp file as a hardlink to a file they can read would otherwise be handed
|
|
||||||
/// `[security].salt`. `mode(0o600)` only applies to a file this call creates,
|
|
||||||
/// which is the same reason.
|
|
||||||
fn write_private_temp(target: &Path, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
let dir = target.parent().unwrap_or_else(|| Path::new("."));
|
let mut opts = fs::OpenOptions::new();
|
||||||
let stem = target
|
opts.write(true).create(true).truncate(true);
|
||||||
.file_name()
|
#[cfg(unix)]
|
||||||
.map(|n| n.to_string_lossy().into_owned())
|
{
|
||||||
.unwrap_or_else(|| "config.toml".to_string());
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
opts.custom_flags(libc::O_NOFOLLOW);
|
||||||
// A handful of attempts, then give up rather than spin: if something is
|
opts.mode(0o600);
|
||||||
// racing us for every name we pick, failing the save is the honest
|
|
||||||
// outcome — the caller treats a failed save as fatal to the change.
|
|
||||||
let mut last_err = None;
|
|
||||||
for _ in 0..8 {
|
|
||||||
let tmp = dir.join(format!("{}.{}.tmp", stem, unique_suffix()));
|
|
||||||
let mut opts = fs::OpenOptions::new();
|
|
||||||
opts.write(true).create_new(true);
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
opts.custom_flags(libc::O_NOFOLLOW);
|
|
||||||
opts.mode(0o600);
|
|
||||||
}
|
|
||||||
match opts.open(&tmp) {
|
|
||||||
Ok(mut f) => {
|
|
||||||
let wrote = f.write_all(bytes).and_then(|()| f.sync_all());
|
|
||||||
return match wrote {
|
|
||||||
Ok(()) => Ok(tmp),
|
|
||||||
Err(e) => {
|
|
||||||
let _ = fs::remove_file(&tmp);
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => last_err = Some(e),
|
|
||||||
Err(e) => return Err(e),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(last_err.unwrap_or_else(|| {
|
let mut f = opts.open(path)?;
|
||||||
std::io::Error::new(
|
f.write_all(bytes)?;
|
||||||
std::io::ErrorKind::AlreadyExists,
|
// The rename that follows is atomic with respect to the *directory*, not
|
||||||
"could not create a temporary config file",
|
// to the file's contents: without this, a crash can leave the new name
|
||||||
)
|
// pointing at a block of zeroes.
|
||||||
}))
|
f.sync_all()
|
||||||
}
|
|
||||||
|
|
||||||
/// How old an abandoned temp file must be before [`sweep_stale_temps`] takes
|
|
||||||
/// it. A save is a serialize, a write and a rename — milliseconds — so an hour
|
|
||||||
/// is far past any doubt, while still short enough that leftovers do not
|
|
||||||
/// accumulate across a run of crashes.
|
|
||||||
const TEMP_SWEEP_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60);
|
|
||||||
|
|
||||||
/// Delete abandoned `<config name>.*.tmp` files beside the config.
|
|
||||||
///
|
|
||||||
/// [`write_private_temp`] must use a *unique* name — a fixed one can be
|
|
||||||
/// pre-created by someone else as a hardlink — and a unique name is one that
|
|
||||||
/// nothing later overwrites, so a process that dies between `create_new` and
|
|
||||||
/// the rename leaves its temp file behind for good. The old fixed
|
|
||||||
/// `config.toml.tmp` was reused by the next save and so never accumulated;
|
|
||||||
/// this is what replaces that property.
|
|
||||||
///
|
|
||||||
/// Age is the discriminator, not the recorded PID: a temp file created seconds
|
|
||||||
/// ago may belong to a save running *right now* in another process, and
|
|
||||||
/// deleting that would destroy the very write this whole dance protects.
|
|
||||||
/// Entirely best-effort — a directory that cannot be listed is not a reason to
|
|
||||||
/// fail a save that already succeeded.
|
|
||||||
fn sweep_stale_temps(dir: &Path, target: &Path) {
|
|
||||||
let Some(stem) = target.file_name().and_then(|n| n.to_str()) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
// The trailing dot matters: without it the config file itself would be a
|
|
||||||
// prefix match.
|
|
||||||
let prefix = format!("{}.", stem);
|
|
||||||
let Ok(entries) = fs::read_dir(dir) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let name = entry.file_name();
|
|
||||||
let Some(name) = name.to_str() else { continue };
|
|
||||||
if !name.starts_with(&prefix) || !name.ends_with(".tmp") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let abandoned = entry
|
|
||||||
.metadata()
|
|
||||||
.and_then(|m| m.modified())
|
|
||||||
.is_ok_and(|t| t.elapsed().is_ok_and(|age| age >= TEMP_SWEEP_AGE));
|
|
||||||
if abandoned {
|
|
||||||
let _ = fs::remove_file(entry.path());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A short, non-guessable-enough suffix for the temp name.
|
|
||||||
///
|
|
||||||
/// This does not need to be unpredictable to an attacker — `create_new` is
|
|
||||||
/// what makes the write safe — only unlikely to collide with a leftover from
|
|
||||||
/// an interrupted save, so the process id and a clock reading are plenty.
|
|
||||||
fn unique_suffix() -> String {
|
|
||||||
let nanos = std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.subsec_nanos())
|
|
||||||
.unwrap_or(0);
|
|
||||||
format!("{}-{}", std::process::id(), nanos)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reserved `content_extensions` entry standing for "files with no
|
/// Reserved `content_extensions` entry standing for "files with no
|
||||||
|
|
|
||||||
|
|
@ -1043,113 +1043,3 @@ fn the_search_table_ships_without_size_or_modified() {
|
||||||
assert_eq!(older.search.display_limit, 500);
|
assert_eq!(older.search.display_limit, 500);
|
||||||
assert!(older.search.live_results, "live results default to on");
|
assert!(older.search.live_results, "live results default to on");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The guard the live watcher and duplicate verification consult before
|
|
||||||
/// opening a path that came from a `files` row. See
|
|
||||||
/// [`crate::file_handling::index_file_set`] for what opening one costs.
|
|
||||||
#[test]
|
|
||||||
fn the_index_and_every_sidecar_are_recognised() {
|
|
||||||
let mut c = Config::default();
|
|
||||||
c.paths.database_path = "/var/lib/qs/index.sqlite".to_string();
|
|
||||||
|
|
||||||
for name in [
|
|
||||||
"index.sqlite",
|
|
||||||
"index.sqlite-wal",
|
|
||||||
"index.sqlite-shm",
|
|
||||||
"index.sqlite-journal",
|
|
||||||
"index.sqlite.lock",
|
|
||||||
] {
|
|
||||||
let p = PathBuf::from(format!("/var/lib/qs/{}", name));
|
|
||||||
assert!(c.is_index_file(&p), "{} should be recognised", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A near miss on the name, and the right name in the wrong directory.
|
|
||||||
for p in [
|
|
||||||
"/var/lib/qs/index.sqlite-walrus",
|
|
||||||
"/var/lib/qs/notes-wal",
|
|
||||||
"/var/lib/qs/index.sqlite2",
|
|
||||||
"/var/lib/other/index.sqlite-wal",
|
|
||||||
"/var/lib/qs/sub/index.sqlite",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!c.is_index_file(Path::new(p)),
|
|
||||||
"{} is not part of the index",
|
|
||||||
p
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `database_path` is resolved the same way everywhere else resolves it, so a
|
|
||||||
/// `~` or a relative spelling has to compare equal to the absolute file.
|
|
||||||
#[test]
|
|
||||||
fn a_tilde_database_path_still_matches_the_real_file() {
|
|
||||||
let Some(home) = crate::platform::home_dir() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let mut c = Config::default();
|
|
||||||
c.paths.database_path = "~/.local/share/quicksearch/index.sqlite".to_string();
|
|
||||||
|
|
||||||
let absolute = PathBuf::from(home).join(".local/share/quicksearch/index.sqlite");
|
|
||||||
assert!(c.is_index_file(&absolute));
|
|
||||||
let wal = absolute.with_file_name("index.sqlite-wal");
|
|
||||||
assert!(c.is_index_file(&wal));
|
|
||||||
assert!(!c.is_index_file(&absolute.with_file_name("other.sqlite")));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `display_limit = 0` makes `cascade::run` compute `remaining()` as zero and
|
|
||||||
/// stop before its first pass, so *every* search returns nothing and reports
|
|
||||||
/// itself truncated. A hand-editable file must not be able to do that.
|
|
||||||
#[test]
|
|
||||||
fn out_of_range_numbers_are_clamped_not_rejected() {
|
|
||||||
let dir = tmp_dir();
|
|
||||||
let path = dir.join("config.toml");
|
|
||||||
fs::write(
|
|
||||||
&path,
|
|
||||||
"[search]\ndisplay_limit=0\n\
|
|
||||||
[processing]\nmaximum_text_file_size=0\nhash_length=8\nwriter_turn_slice_ms=18446744073709551615\n",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let cfg = Config::load_from(&path).expect("a bad number must not stop the app starting");
|
|
||||||
assert_eq!(cfg.search.display_limit, 1);
|
|
||||||
assert_eq!(cfg.processing.maximum_text_file_size, 1);
|
|
||||||
assert_eq!(cfg.processing.hash_length, 262);
|
|
||||||
assert_eq!(cfg.processing.writer_turn_slice_ms, 10_000);
|
|
||||||
fs::remove_dir_all(&dir).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The clamp must be silent about values that are merely unusual, or the
|
|
||||||
/// warning is noise and `hash_length` — which is in `REBUILD_KEYS` — would
|
|
||||||
/// read as changed and force a rebuild on every start.
|
|
||||||
#[test]
|
|
||||||
fn in_range_numbers_are_left_exactly_alone() {
|
|
||||||
let mut cfg = Config::default();
|
|
||||||
let before = cfg.clone();
|
|
||||||
let warnings = cfg.clamp_out_of_range();
|
|
||||||
assert!(warnings.is_empty(), "defaults must not warn: {warnings:?}");
|
|
||||||
assert_eq!(cfg.search.display_limit, before.search.display_limit);
|
|
||||||
assert_eq!(cfg.processing.hash_length, before.processing.hash_length);
|
|
||||||
assert_eq!(
|
|
||||||
cfg.processing.maximum_text_file_size,
|
|
||||||
before.processing.maximum_text_file_size
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
cfg.processing.writer_turn_slice_ms,
|
|
||||||
before.processing.writer_turn_slice_ms
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zero is a legal turn slice — one quantum per turn, not "no turn".
|
|
||||||
cfg.processing.writer_turn_slice_ms = 0;
|
|
||||||
assert!(cfg.clamp_out_of_range().is_empty());
|
|
||||||
assert_eq!(cfg.processing.writer_turn_slice_ms, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Each clamped field must say so, by name, so the warning is actionable.
|
|
||||||
#[test]
|
|
||||||
fn a_clamped_field_is_named_in_its_warning() {
|
|
||||||
let mut cfg = Config::default();
|
|
||||||
cfg.search.display_limit = 0;
|
|
||||||
let warnings = cfg.clamp_out_of_range();
|
|
||||||
assert_eq!(warnings.len(), 1, "{warnings:?}");
|
|
||||||
assert!(warnings[0].contains("display_limit"), "{warnings:?}");
|
|
||||||
assert!(warnings[0].contains('1'), "{warnings:?}");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ struct Shared {
|
||||||
queue: Mutex<Queue>,
|
queue: Mutex<Queue>,
|
||||||
idle: Condvar,
|
idle: Condvar,
|
||||||
/// What the range held when the pass began: rows still to extract and
|
/// What the range held when the pass began: rows still to extract and
|
||||||
/// rows already done. Set by the feeder just *behind* its first page, so
|
/// rows already done. Set by the feeder before it pages anything, so
|
||||||
/// the pool is never blocked on the scan that produces it; never set if the
|
/// `already_done + rows written this pass` stays exact; never set if the
|
||||||
/// feeder could not count. See `feeder` for what deferring it costs.
|
/// feeder could not count.
|
||||||
totals: std::sync::OnceLock<ExtractScope>,
|
totals: std::sync::OnceLock<ExtractScope>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,10 +173,9 @@ impl ContentPass {
|
||||||
/// The range's pending and already-done counts as they stood when the
|
/// The range's pending and already-done counts as they stood when the
|
||||||
/// pass began.
|
/// pass began.
|
||||||
///
|
///
|
||||||
/// `None` until the feeder has counted — a scan measured at 513 ms over a
|
/// `None` until the feeder has counted — a scan that takes seconds on a
|
||||||
/// million rows, which is why it happens on the pass's own connection
|
/// large root, which is why it happens here on the pass's own connection
|
||||||
/// rather than the indexer's writer, and behind the first page rather than
|
/// and not on the indexer's writer — and forever if it could not.
|
||||||
/// in front of the pool — and forever if it could not.
|
|
||||||
pub fn totals(&self) -> Option<ExtractScope> {
|
pub fn totals(&self) -> Option<ExtractScope> {
|
||||||
self.shared.totals.get().copied()
|
self.shared.totals.get().copied()
|
||||||
}
|
}
|
||||||
|
|
@ -225,36 +224,16 @@ fn feeder(shared: &Shared, db_path: &str, mut cursor: ExtractCursor, config: &Co
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// The count happens *behind* the first page, not in front of it.
|
// Before the first page, so nothing this pass writes is inside the count.
|
||||||
//
|
// The workers cannot run ahead of this: they block in `take` until the
|
||||||
// It used to run here, before anything was fetched, and the workers block
|
// first page lands. A failure here costs the progress figure, not the
|
||||||
// in `take` until a page lands — so every thread in the pool sat idle for
|
// pass.
|
||||||
// the whole of it, to compute a progress-bar denominator. That is not
|
match crate::file_handling::count_extract_scope(&conn, &cursor, config) {
|
||||||
// free: the scan walks the root's entire parent range fetching a row per
|
Ok(totals) => {
|
||||||
// entry, measured at 20 ms over 100,000 rows and **513 ms over a million**
|
let _ = shared.totals.set(totals);
|
||||||
// with the index already in cache, and the count was moved onto this
|
|
||||||
// connection in the first place because on a large root it takes seconds
|
|
||||||
// cold. The move took it off the writer and left the stall one level down.
|
|
||||||
//
|
|
||||||
// What it costs to defer: rows this pass writes during the count can be
|
|
||||||
// seen by it as `already_done` rather than `pending`. The two move in
|
|
||||||
// opposite directions and `extract_total` is their **sum**, so the
|
|
||||||
// denominator is unaffected; only the numerator can run briefly ahead of
|
|
||||||
// itself, which is a shape `RootProgress` already reports and deliberately
|
|
||||||
// does not clamp — see the note on `snapshot`.
|
|
||||||
let mut counted = false;
|
|
||||||
let mut count_now = |conn: &rusqlite::Connection, cursor: &ExtractCursor| {
|
|
||||||
if counted {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
counted = true;
|
Err(e) => crate::log_warn!("content reader: {}", e),
|
||||||
match crate::file_handling::count_extract_scope(conn, cursor, config) {
|
}
|
||||||
Ok(totals) => {
|
|
||||||
let _ = shared.totals.set(totals);
|
|
||||||
}
|
|
||||||
Err(e) => crate::log_warn!("content reader: {}", e),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let max_size = crate::file_handling::max_text_file_size(config);
|
let max_size = crate::file_handling::max_text_file_size(config);
|
||||||
while shared.take_feed_slot().is_some() {
|
while shared.take_feed_slot().is_some() {
|
||||||
|
|
@ -282,16 +261,10 @@ fn feeder(shared: &Shared, db_path: &str, mut cursor: ExtractCursor, config: &Co
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
shared.finish_feed(rows, last_page);
|
shared.finish_feed(rows, last_page);
|
||||||
// The pool is running now; the denominator can be worked out behind it.
|
|
||||||
count_now(&conn, &cursor);
|
|
||||||
if last_page {
|
if last_page {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A range whose first `take_feed_slot` said the pass was already over
|
|
||||||
// still deserves its figure — `an_empty_range_terminates_immediately`
|
|
||||||
// pins that an empty root reports a known zero rather than an unknown.
|
|
||||||
count_now(&conn, &cursor);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker(
|
fn worker(
|
||||||
|
|
@ -423,7 +396,8 @@ mod tests {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: f.file_name().unwrap().to_str().unwrap(),
|
name: f.file_name().unwrap().to_str().unwrap(),
|
||||||
parent: &crate::file_handling::dir_to_db_parent(&d),
|
path: f.to_str().unwrap(),
|
||||||
|
parent: d.to_str().unwrap(),
|
||||||
size: std::fs::metadata(&f).unwrap().len(),
|
size: std::fs::metadata(&f).unwrap().len(),
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -520,7 +494,7 @@ mod tests {
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT content_state FROM files WHERE parent || name = ?1",
|
"SELECT content_state FROM files WHERE path = ?1",
|
||||||
rusqlite::params![p.to_str().unwrap()],
|
rusqlite::params![p.to_str().unwrap()],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -234,8 +234,6 @@ impl IndexCoordinator {
|
||||||
last_event_at: None,
|
last_event_at: None,
|
||||||
pending_since: None,
|
pending_since: None,
|
||||||
needs_full_run: false,
|
needs_full_run: false,
|
||||||
run_retry_at: None,
|
|
||||||
run_retry_delay: inner::RUN_RETRY_BASE,
|
|
||||||
pending_work: None,
|
pending_work: None,
|
||||||
reconcile_done: None,
|
reconcile_done: None,
|
||||||
reconcile_cut_short: false,
|
reconcile_cut_short: false,
|
||||||
|
|
|
||||||
|
|
@ -3,32 +3,6 @@
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// First wait after a full run is refused or fails to start.
|
|
||||||
pub(super) const RUN_RETRY_BASE: Duration = Duration::from_secs(30);
|
|
||||||
|
|
||||||
/// Ceiling for that wait. A misconfiguration that is never fixed then costs
|
|
||||||
/// one attempt every few minutes instead of one per second.
|
|
||||||
const RUN_RETRY_MAX: Duration = Duration::from_secs(300);
|
|
||||||
|
|
||||||
/// Individually-logged occurrences of each refusal before it goes quiet. The
|
|
||||||
/// message is identical every time, and the log ring is the only record of
|
|
||||||
/// what *else* happened; these are reset the moment a run starts.
|
|
||||||
static NO_ROOTS: crate::log::Throttle = crate::log::Throttle::new(3);
|
|
||||||
static NESTED_ROOTS: crate::log::Throttle = crate::log::Throttle::new(3);
|
|
||||||
static START_FAILURES: crate::log::Throttle = crate::log::Throttle::new(3);
|
|
||||||
|
|
||||||
/// Why a full run is being started, which is what decides how a refusal is
|
|
||||||
/// reported. See [`Inner::refuse`].
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub(super) enum RunTrigger {
|
|
||||||
/// Something the user did: *Index now*, *Rebuild index*, or a settings
|
|
||||||
/// change wide enough to need a walk. A refusal owes them an answer.
|
|
||||||
Requested,
|
|
||||||
/// The periodic timer, or repair the coordinator scheduled for itself. A
|
|
||||||
/// refusal here is worth one log line and then quiet.
|
|
||||||
Scheduled,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) struct Inner {
|
pub(super) struct Inner {
|
||||||
pub(super) config: Config,
|
pub(super) config: Config,
|
||||||
pub(super) indexing: Arc<IndexingService>,
|
pub(super) indexing: Arc<IndexingService>,
|
||||||
|
|
@ -66,18 +40,6 @@ pub(super) struct Inner {
|
||||||
/// defer application past `pending_max_defer`.
|
/// defer application past `pending_max_defer`.
|
||||||
pub(super) pending_since: Option<Instant>,
|
pub(super) pending_since: Option<Instant>,
|
||||||
pub(super) needs_full_run: bool,
|
pub(super) needs_full_run: bool,
|
||||||
/// Earliest time another full run may be *attempted*, when the last one
|
|
||||||
/// was refused or failed. Without it a config with no roots, or with
|
|
||||||
/// nested roots, or an index on a full disk, retries every tick forever:
|
|
||||||
/// two stderr writes and two ring pushes a second, which evicts the whole
|
|
||||||
/// 5000-line log ring in under an hour and takes the diagnostics for the
|
|
||||||
/// actual problem with it — and in the failed-run case re-walks the tree
|
|
||||||
/// each time.
|
|
||||||
pub(super) run_retry_at: Option<Instant>,
|
|
||||||
/// How long the next refusal waits, doubling to [`RUN_RETRY_MAX`] so a
|
|
||||||
/// permanent misconfiguration costs almost nothing, and reset once a run
|
|
||||||
/// actually starts.
|
|
||||||
pub(super) run_retry_delay: Duration,
|
|
||||||
/// Reconciliation owed to a config change, part-applied across ticks.
|
/// Reconciliation owed to a config change, part-applied across ticks.
|
||||||
/// Unlike `needs_full_run`, this is acted on in manual mode too.
|
/// Unlike `needs_full_run`, this is acted on in manual mode too.
|
||||||
pub(super) pending_work: Option<WorkCursor>,
|
pub(super) pending_work: Option<WorkCursor>,
|
||||||
|
|
@ -133,7 +95,7 @@ impl Inner {
|
||||||
CoordCmd::SetMode(IndexMode::ManualStopped) => self.enter_manual_stopped(),
|
CoordCmd::SetMode(IndexMode::ManualStopped) => self.enter_manual_stopped(),
|
||||||
// ManualRunning isn't directly settable; ReindexNow is the verb.
|
// ManualRunning isn't directly settable; ReindexNow is the verb.
|
||||||
CoordCmd::SetMode(IndexMode::ManualRunning) | CoordCmd::ReindexNow => {
|
CoordCmd::SetMode(IndexMode::ManualRunning) | CoordCmd::ReindexNow => {
|
||||||
self.start_full_run(RunTrigger::Requested);
|
self.start_full_run();
|
||||||
if self.mode != IndexMode::Auto {
|
if self.mode != IndexMode::Auto {
|
||||||
self.mode = IndexMode::ManualRunning;
|
self.mode = IndexMode::ManualRunning;
|
||||||
}
|
}
|
||||||
|
|
@ -177,18 +139,10 @@ impl Inner {
|
||||||
self.pending_work = None;
|
self.pending_work = None;
|
||||||
self.reconcile_done = None;
|
self.reconcile_done = None;
|
||||||
if let Err(e) = self.indexing.delete_index_for_rebuild(&db) {
|
if let Err(e) = self.indexing.delete_index_for_rebuild(&db) {
|
||||||
// Not a warning to step over: a run started now would
|
|
||||||
// reopen the *old* index and present itself as the rebuild
|
|
||||||
// the user asked for. Worse after a password change, where
|
|
||||||
// the config already claims a protection the surviving
|
|
||||||
// file does not have. Report it and leave the index alone.
|
|
||||||
crate::log_warn!("coordinator: rebuild: {}", e);
|
crate::log_warn!("coordinator: rebuild: {}", e);
|
||||||
self.indexing
|
|
||||||
.report_error(format!("could not rebuild the index: {}", e));
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
self.clear_root_counts();
|
self.clear_root_counts();
|
||||||
self.start_full_run(RunTrigger::Requested);
|
self.start_full_run();
|
||||||
if self.mode != IndexMode::Auto {
|
if self.mode != IndexMode::Auto {
|
||||||
self.mode = IndexMode::ManualRunning;
|
self.mode = IndexMode::ManualRunning;
|
||||||
}
|
}
|
||||||
|
|
@ -220,7 +174,7 @@ impl Inner {
|
||||||
// delivers anything else, so nothing downstream checks, and
|
// delivers anything else, so nothing downstream checks, and
|
||||||
// a file renamed *out* of every root would otherwise be
|
// a file renamed *out* of every root would otherwise be
|
||||||
// written into the index at its new home. Roots in the same
|
// written into the index at its new home. Roots in the same
|
||||||
// spelling the index uses — the caller's paths are.
|
// spelling `files.path` uses — the caller's paths are.
|
||||||
let prefixes: Vec<String> = self
|
let prefixes: Vec<String> = self
|
||||||
.config
|
.config
|
||||||
.normalized_indexing_paths()
|
.normalized_indexing_paths()
|
||||||
|
|
@ -270,12 +224,6 @@ impl Inner {
|
||||||
if self.saw_running {
|
if self.saw_running {
|
||||||
self.saw_running = false;
|
self.saw_running = false;
|
||||||
self.was_busy = true;
|
self.was_busy = true;
|
||||||
// A run that errored never stamps `last_full_index`, so
|
|
||||||
// `periodic_due` stays true and the next tick starts another one —
|
|
||||||
// a fresh whole-tree walk per second against, say, a full disk.
|
|
||||||
if matches!(status, IndexingStatus::Error(_)) {
|
|
||||||
self.defer_runs();
|
|
||||||
}
|
|
||||||
self.refresh_last_full_index();
|
self.refresh_last_full_index();
|
||||||
// Eager re-read: the run just changed the number on screen.
|
// Eager re-read: the run just changed the number on screen.
|
||||||
self.files_at = None;
|
self.files_at = None;
|
||||||
|
|
@ -315,9 +263,8 @@ impl Inner {
|
||||||
worked = true;
|
worked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
let deferred = self.run_retry_at.is_some_and(|at| Instant::now() < at);
|
if self.needs_full_run || self.periodic_due() {
|
||||||
if (self.needs_full_run || self.periodic_due()) && !deferred {
|
self.start_full_run();
|
||||||
self.start_full_run(RunTrigger::Scheduled);
|
|
||||||
worked = true;
|
worked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -461,7 +408,7 @@ impl Inner {
|
||||||
// Widening adds files only a walk can produce; mirrors `ReindexNow`,
|
// Widening adds files only a walk can produce; mirrors `ReindexNow`,
|
||||||
// including the manual-mode round trip back to stopped.
|
// including the manual-mode round trip back to stopped.
|
||||||
if cursor.reindex() {
|
if cursor.reindex() {
|
||||||
self.start_full_run(RunTrigger::Requested);
|
self.start_full_run();
|
||||||
if self.mode != IndexMode::Auto {
|
if self.mode != IndexMode::Auto {
|
||||||
self.mode = IndexMode::ManualRunning;
|
self.mode = IndexMode::ManualRunning;
|
||||||
}
|
}
|
||||||
|
|
@ -608,9 +555,6 @@ impl Inner {
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::log_warn!("coordinator: targeted update unavailable: {}", e);
|
crate::log_warn!("coordinator: targeted update unavailable: {}", e);
|
||||||
self.targeted.clear();
|
self.targeted.clear();
|
||||||
// As in `start_full_run`: a resume point outlives its event
|
|
||||||
// here too, and would misapply to the next one for that path.
|
|
||||||
self.resume_from.retain(|p, _| self.pending.contains_key(p));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -689,15 +633,10 @@ impl Inner {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn periodic_due(&self) -> bool {
|
fn periodic_due(&self) -> bool {
|
||||||
// `.max(1)`: at zero every tick is "due", so a full run would start
|
|
||||||
// the moment the previous one finished, forever. There is no setting
|
|
||||||
// that means "reindex continuously" — manual mode is how you say
|
|
||||||
// "never", so zero is a typo rather than an intent.
|
|
||||||
let interval_secs = self
|
let interval_secs = self
|
||||||
.config
|
.config
|
||||||
.indexing
|
.indexing
|
||||||
.reindex_interval_minutes
|
.reindex_interval_minutes
|
||||||
.max(1)
|
|
||||||
.saturating_mul(60);
|
.saturating_mul(60);
|
||||||
let last = crate::lock_ok(&self.shared).last_full_index;
|
let last = crate::lock_ok(&self.shared).last_full_index;
|
||||||
match last {
|
match last {
|
||||||
|
|
@ -712,20 +651,7 @@ impl Inner {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_full_run(&mut self, trigger: RunTrigger) {
|
fn start_full_run(&mut self) {
|
||||||
// A run already in progress is not a refusal: whoever called this
|
|
||||||
// wanted a run and there is one. Returning here rather than falling
|
|
||||||
// through to `start_indexing`'s "already running" error keeps an
|
|
||||||
// *Index now* pressed mid-run from backing off future runs and from
|
|
||||||
// replacing the progress the user is watching with an error. It also
|
|
||||||
// covers `Optimizing`, which `start_indexing` does not reject even
|
|
||||||
// though it holds a write transaction over the whole file.
|
|
||||||
if !matches!(
|
|
||||||
self.indexing.get_status(),
|
|
||||||
IndexingStatus::Idle | IndexingStatus::Error(_)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let roots: Vec<String> = self
|
let roots: Vec<String> = self
|
||||||
.config
|
.config
|
||||||
.resolved_indexing_paths()
|
.resolved_indexing_paths()
|
||||||
|
|
@ -733,23 +659,20 @@ impl Inner {
|
||||||
.map(|p| p.to_string_lossy().into_owned())
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
.collect();
|
.collect();
|
||||||
if roots.is_empty() {
|
if roots.is_empty() {
|
||||||
self.refuse(trigger, &NO_ROOTS, "no indexing roots are configured");
|
crate::log_warn!("coordinator: no indexing roots configured");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Backstop for hand-edited configs; the GUI rejects nested roots
|
// Backstop for hand-edited configs; the GUI rejects nested roots
|
||||||
// itself.
|
// itself.
|
||||||
let nested = crate::config::nested_roots(&roots);
|
let nested = crate::config::nested_roots(&roots);
|
||||||
if !nested.is_empty() {
|
if !nested.is_empty() {
|
||||||
let detail = nested
|
for (child, parent) in &nested {
|
||||||
.iter()
|
crate::log_warn!(
|
||||||
.map(|(child, parent)| format!("{} is nested under {}", child, parent))
|
"coordinator: refusing to index: root {} is nested under {}",
|
||||||
.collect::<Vec<_>>()
|
child,
|
||||||
.join("; ");
|
parent
|
||||||
self.refuse(
|
);
|
||||||
trigger,
|
}
|
||||||
&NESTED_ROOTS,
|
|
||||||
&format!("refusing to index nested roots: {}", detail),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// The full run owns the DB (and may wipe/rebuild the file).
|
// The full run owns the DB (and may wipe/rebuild the file).
|
||||||
|
|
@ -760,12 +683,6 @@ impl Inner {
|
||||||
// unreadable directory (`unreadable.covers`) or of an aliased symlink
|
// unreadable directory (`unreadable.covers`) or of an aliased symlink
|
||||||
// target (`aliased_paths`), and those rows would leak until a rebuild.
|
// target (`aliased_paths`), and those rows would leak until a rebuild.
|
||||||
self.pending.retain(|_, ev| is_removal(ev));
|
self.pending.retain(|_, ev| is_removal(ev));
|
||||||
// Resume points describe events that no longer exist. Left behind, the
|
|
||||||
// next `Create` for that path would `skip` the first n entries of a
|
|
||||||
// walk that has nothing to do with the one the count came from, and
|
|
||||||
// those files would have no row until a later full run.
|
|
||||||
self.resume_from
|
|
||||||
.retain(|p, _| self.pending.contains_key(p) || self.targeted.contains_key(p));
|
|
||||||
if self.pending.is_empty() {
|
if self.pending.is_empty() {
|
||||||
self.last_event_at = None;
|
self.last_event_at = None;
|
||||||
self.pending_since = None;
|
self.pending_since = None;
|
||||||
|
|
@ -774,58 +691,15 @@ impl Inner {
|
||||||
.indexing
|
.indexing
|
||||||
.start_indexing(roots, self.db_path(), self.config.clone())
|
.start_indexing(roots, self.db_path(), self.config.clone())
|
||||||
{
|
{
|
||||||
self.refuse(
|
crate::log_warn!("coordinator: start indexing: {}", e);
|
||||||
trigger,
|
|
||||||
&START_FAILURES,
|
|
||||||
&format!("could not start indexing: {}", e),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// A run started, so whatever was wrong is not wrong any more.
|
|
||||||
self.run_retry_at = None;
|
|
||||||
self.run_retry_delay = RUN_RETRY_BASE;
|
|
||||||
NO_ROOTS.reset();
|
|
||||||
NESTED_ROOTS.reset();
|
|
||||||
START_FAILURES.reset();
|
|
||||||
// `start_indexing` claims Running before returning, so there is no
|
// `start_indexing` claims Running before returning, so there is no
|
||||||
// window in which this thread believes the service idle and writes to
|
// window in which this thread believes the service idle and writes to
|
||||||
// a database the run is about to reopen.
|
// a database the run is about to reopen.
|
||||||
self.saw_running = true;
|
self.saw_running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse a full run: back off, and say so at a volume the trigger earns.
|
|
||||||
///
|
|
||||||
/// The throttles exist for the *scheduled* retry, which repeats the same
|
|
||||||
/// sentence every few minutes for as long as the configuration stays
|
|
||||||
/// broken and would otherwise evict the log ring. They must not silence a
|
|
||||||
/// run somebody asked for: after three refusals a fourth press of *Index
|
|
||||||
/// now* would log nothing, show nothing and change nothing, which reads as
|
|
||||||
/// a button that does not work. So a requested run resets the throttle —
|
|
||||||
/// it is a fresh occurrence in the user's eyes — and publishes the reason
|
|
||||||
/// where they are already looking, the same way a failed rebuild does.
|
|
||||||
fn refuse(&mut self, trigger: RunTrigger, throttle: &crate::log::Throttle, reason: &str) {
|
|
||||||
self.defer_runs();
|
|
||||||
if trigger == RunTrigger::Requested {
|
|
||||||
throttle.reset();
|
|
||||||
self.indexing.report_error(reason.to_string());
|
|
||||||
}
|
|
||||||
if throttle.allow() {
|
|
||||||
crate::log_warn!("coordinator: {}", reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hold off further full runs for a while, doubling the wait each time.
|
|
||||||
///
|
|
||||||
/// Called wherever a run is refused or fails to start. `needs_full_run` is
|
|
||||||
/// cleared with it: the two refusal paths sit above the point that clears
|
|
||||||
/// it, so without this the flag stays set and `tick` retries immediately,
|
|
||||||
/// every second, for as long as the configuration stays broken.
|
|
||||||
fn defer_runs(&mut self) {
|
|
||||||
self.needs_full_run = false;
|
|
||||||
self.run_retry_at = Some(Instant::now() + self.run_retry_delay);
|
|
||||||
self.run_retry_delay = (self.run_retry_delay * 2).min(RUN_RETRY_MAX);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enter_auto(&mut self) {
|
fn enter_auto(&mut self) {
|
||||||
self.mode = IndexMode::Auto;
|
self.mode = IndexMode::Auto;
|
||||||
self.config.indexing.auto_index = true;
|
self.config.indexing.auto_index = true;
|
||||||
|
|
@ -937,29 +811,6 @@ impl Inner {
|
||||||
let Some(mut reason) = w.degraded_reason() else {
|
let Some(mut reason) = w.degraded_reason() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
// An overflow is not a capacity problem: the watcher is still good,
|
|
||||||
// it just cannot tell us what it missed. Schedule the run that finds
|
|
||||||
// out, rather than disabling live updates for the session over
|
|
||||||
// something a busy minute can cause.
|
|
||||||
//
|
|
||||||
// Not a restart. `inotify` and `ReadDirectoryChangesW` both keep
|
|
||||||
// delivering after their queue overflows — the rescan flag means "you
|
|
||||||
// missed some", not "this watch is broken" — so tearing down and
|
|
||||||
// re-registering would re-`stat` every one of `max_watched_dirs`
|
|
||||||
// (128k) directories, generate events of its own, and do it again the
|
|
||||||
// next time a busy tree overflowed the queue, which is exactly when
|
|
||||||
// the machine can least afford it.
|
|
||||||
//
|
|
||||||
// Consuming the reason is what makes this a one-shot: the slot is
|
|
||||||
// never otherwise cleared, so a standing `Overflowed` would re-arm
|
|
||||||
// `needs_full_run` on every tick and hide a `KernelLimit` recorded
|
|
||||||
// afterwards.
|
|
||||||
if matches!(reason, WatchError::Overflowed) {
|
|
||||||
w.clear_degraded();
|
|
||||||
crate::log_warn!("watcher: {}", reason);
|
|
||||||
self.needs_full_run = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The async notify callback can't know the count; fill it in here.
|
// The async notify callback can't know the count; fill it in here.
|
||||||
if let WatchError::KernelLimit { registered } = &mut reason {
|
if let WatchError::KernelLimit { registered } = &mut reason {
|
||||||
if *registered == 0 {
|
if *registered == 0 {
|
||||||
|
|
|
||||||
|
|
@ -512,7 +512,7 @@ impl Fixture {
|
||||||
fn stored_mtime(&self, path: &std::path::Path) -> Option<i64> {
|
fn stored_mtime(&self, path: &std::path::Path) -> Option<i64> {
|
||||||
let conn = db::open_existing(&self.db.to_string_lossy(), false).ok()?;
|
let conn = db::open_existing(&self.db.to_string_lossy(), false).ok()?;
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT mtime FROM files WHERE parent || name = ?1",
|
"SELECT mtime FROM files WHERE path = ?1",
|
||||||
[path.to_string_lossy().as_ref()],
|
[path.to_string_lossy().as_ref()],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ pub mod schema;
|
||||||
|
|
||||||
pub use key::{process_key_hex, set_process_key};
|
pub use key::{process_key_hex, set_process_key};
|
||||||
pub use open::{
|
pub use open::{
|
||||||
index_needs_rebuild, key_mismatch_parts, open_existing, open_or_recreate, verify_process_key,
|
index_needs_rebuild, open_existing, open_or_recreate, verify_process_key,
|
||||||
KeyMismatch, CURRENT_SCHEMA_VERSION, FOREIGN_DB_PREFIX, KEY_MISMATCH_PREFIX,
|
CURRENT_SCHEMA_VERSION, KEY_MISMATCH_PREFIX,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bumped whenever the index file is replaced rather than modified — a
|
/// Bumped whenever the index file is replaced rather than modified — a
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ pub const KEY_MISMATCH_PREFIX: &str = "KEY_MISMATCH: ";
|
||||||
/// values go stale: `files.mime`, `files.type` and `content_state` are
|
/// values go stale: `files.mime`, `files.type` and `content_state` are
|
||||||
/// computed at walk time and never re-derived for unchanged files, so a
|
/// computed at walk time and never re-derived for unchanged files, so a
|
||||||
/// classification change needs the wipe to apply everywhere.
|
/// classification change needs the wipe to apply everywhere.
|
||||||
pub const CURRENT_SCHEMA_VERSION: u32 = 8;
|
pub const CURRENT_SCHEMA_VERSION: u32 = 7;
|
||||||
|
|
||||||
/// Open `db_path` and ensure the on-disk schema matches this build; if it
|
/// Open `db_path` and ensure the on-disk schema matches this build; if it
|
||||||
/// doesn't (including a changed `tokenizer`), delete the file and recreate it
|
/// doesn't (including a changed `tokenizer`), delete the file and recreate it
|
||||||
|
|
@ -241,57 +241,6 @@ fn is_notadb(e: &rusqlite::Error) -> bool {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why a keyed open failed, as something the caller can branch on.
|
|
||||||
///
|
|
||||||
/// The three cases want three different things from a user — retype the
|
|
||||||
/// password, rebuild the index, supply a password at all — and only one of
|
|
||||||
/// them is "wrong password". They used to be distinguishable only by reading
|
|
||||||
/// the English in the message, which breaks the moment a database path
|
|
||||||
/// happens to contain that English.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum KeyMismatch {
|
|
||||||
/// A key was applied and the file did not accept it.
|
|
||||||
WrongPassword,
|
|
||||||
/// A key was applied but the file on disk is not encrypted at all —
|
|
||||||
/// protection was enabled and the rebuild that would encrypt it did not
|
|
||||||
/// finish.
|
|
||||||
NotEncrypted,
|
|
||||||
/// No key was applied and the file wants one.
|
|
||||||
PasswordRequired,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KeyMismatch {
|
|
||||||
/// The machine-readable token carried in the message, between
|
|
||||||
/// [`KEY_MISMATCH_PREFIX`] and the human detail.
|
|
||||||
fn token(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
KeyMismatch::WrongPassword => "wrong-password",
|
|
||||||
KeyMismatch::NotEncrypted => "not-encrypted",
|
|
||||||
KeyMismatch::PasswordRequired => "password-required",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_token(token: &str) -> Option<KeyMismatch> {
|
|
||||||
match token {
|
|
||||||
"wrong-password" => Some(KeyMismatch::WrongPassword),
|
|
||||||
"not-encrypted" => Some(KeyMismatch::NotEncrypted),
|
|
||||||
"password-required" => Some(KeyMismatch::PasswordRequired),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a tagged mismatch message into its cause and the human detail.
|
|
||||||
///
|
|
||||||
/// `None` for any message that is not one — including a `KEY_MISMATCH_PREFIX`
|
|
||||||
/// message from an older build, which callers should treat as they always did.
|
|
||||||
pub fn key_mismatch_parts(message: &str) -> Option<(KeyMismatch, &str)> {
|
|
||||||
let rest = message.strip_prefix(KEY_MISMATCH_PREFIX)?;
|
|
||||||
let (token, detail) = rest.split_once(' ')?;
|
|
||||||
let token = token.strip_suffix(':')?;
|
|
||||||
Some((KeyMismatch::from_token(token)?, detail))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn key_mismatch_message(db_path: &str, had_key: bool) -> String {
|
fn key_mismatch_message(db_path: &str, had_key: bool) -> String {
|
||||||
// An unencrypted SQLite file still has its plaintext magic; sniffing it
|
// An unencrypted SQLite file still has its plaintext magic; sniffing it
|
||||||
// distinguishes "wrong password" from "protection is enabled but the
|
// distinguishes "wrong password" from "protection is enabled but the
|
||||||
|
|
@ -306,122 +255,31 @@ fn key_mismatch_message(db_path: &str, had_key: bool) -> String {
|
||||||
Some(&magic == b"SQLite format 3\0")
|
Some(&magic == b"SQLite format 3\0")
|
||||||
})
|
})
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let (cause, detail) = match (had_key, plaintext) {
|
let detail = match (had_key, plaintext) {
|
||||||
(true, true) => (
|
(true, true) => {
|
||||||
KeyMismatch::NotEncrypted,
|
|
||||||
"password protection is enabled but the index is not encrypted; \
|
"password protection is enabled but the index is not encrypted; \
|
||||||
rebuild the index to encrypt it",
|
rebuild the index to encrypt it"
|
||||||
),
|
}
|
||||||
(true, false) => (
|
(true, false) => "wrong password (or the file is not a QuickSearch index)",
|
||||||
KeyMismatch::WrongPassword,
|
(false, _) => "the index is password-protected; a password is required",
|
||||||
"wrong password (or the file is not a QuickSearch index)",
|
|
||||||
),
|
|
||||||
(false, _) => (
|
|
||||||
KeyMismatch::PasswordRequired,
|
|
||||||
"the index is password-protected; a password is required",
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
// The token sits between the prefix and the detail so that every existing
|
format!("{}index at {}: {}", KEY_MISMATCH_PREFIX, db_path, detail)
|
||||||
// `starts_with(KEY_MISMATCH_PREFIX)` test still holds, while a caller that
|
|
||||||
// needs the cause can have it without reading prose.
|
|
||||||
format!(
|
|
||||||
"{}{}: index at {}: {}",
|
|
||||||
KEY_MISMATCH_PREFIX,
|
|
||||||
cause.token(),
|
|
||||||
db_path,
|
|
||||||
detail
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True iff the DB has a `schema_info` table whose `version` equals
|
/// True iff the DB has a `schema_info` table whose `version` equals
|
||||||
/// [`CURRENT_SCHEMA_VERSION`]. Ignores the tokenizer — that's only the
|
/// [`CURRENT_SCHEMA_VERSION`]. Ignores the tokenizer — that's only the
|
||||||
/// owner's concern.
|
/// owner's concern.
|
||||||
/// Prefix tagging the "this file is not a QuickSearch index" refusal, so a
|
|
||||||
/// caller can tell it from the schema drift that legitimately rebuilds.
|
|
||||||
pub const FOREIGN_DB_PREFIX: &str = "FOREIGN_DB: ";
|
|
||||||
|
|
||||||
/// Tables left behind by the pre-`schema_info` layout, which is the only kind
|
|
||||||
/// of index of ours that [`has_our_schema_info`] cannot recognise.
|
|
||||||
///
|
|
||||||
/// `files` is the only one guaranteed present across those layouts, and it is
|
|
||||||
/// the loose end here: another application's database with a table called
|
|
||||||
/// `files` would still be taken for an ancient index of ours and wiped.
|
|
||||||
/// Refusing a genuine legacy index is the worse failure of the two, so it
|
|
||||||
/// stays — narrowed by the fact that anything with a `schema_info` of our
|
|
||||||
/// shape is already decided before this list is consulted.
|
|
||||||
const LEGACY_TABLES: &[&str] = &["files", "files_fts", "documents_text", "failed_files"];
|
|
||||||
|
|
||||||
/// Whether `schema_info` exists *and* is shaped like ours.
|
|
||||||
///
|
|
||||||
/// The shape, not the contents: preparing the statement succeeds only if the
|
|
||||||
/// table has both columns, and an index whose creation was interrupted before
|
|
||||||
/// the version row landed is still ours. A foreign database that happens to
|
|
||||||
/// use the name for something else is not.
|
|
||||||
fn has_our_schema_info(conn: &Connection) -> bool {
|
|
||||||
conn.prepare("SELECT key, value FROM schema_info").is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the file is one of ours, or empty enough to become one.
|
|
||||||
///
|
|
||||||
/// `sqlite_master` is empty for a file SQLite has just created and for a
|
|
||||||
/// zero-length one, which is the "ours to create" case. Internal `sqlite_%`
|
|
||||||
/// names are excluded so an autoindex or a stat table cannot make an
|
|
||||||
/// otherwise-empty file look occupied.
|
|
||||||
fn is_ours_or_empty(conn: &Connection) -> Result<bool, String> {
|
|
||||||
if has_our_schema_info(conn) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare("SELECT name FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'")
|
|
||||||
.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
||||||
let mut any = false;
|
|
||||||
let names = stmt
|
|
||||||
.query_map([], |r| r.get::<_, String>(0))
|
|
||||||
.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
||||||
for name in names {
|
|
||||||
let name = name.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
||||||
any = true;
|
|
||||||
if LEGACY_TABLES.contains(&name.as_str()) {
|
|
||||||
return Ok(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(!any)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The refusal message, naming a few of the tables that are in the way so the
|
|
||||||
/// user can recognise whose file they pointed at.
|
|
||||||
fn foreign_database_message(conn: &Connection) -> Result<String, String> {
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"SELECT name FROM sqlite_master \
|
|
||||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \
|
|
||||||
ORDER BY name LIMIT 4",
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("read sqlite_master: {}", e))?;
|
|
||||||
let names: Vec<String> = stmt
|
|
||||||
.query_map([], |r| r.get::<_, String>(0))
|
|
||||||
.map_err(|e| format!("read sqlite_master: {}", e))?
|
|
||||||
.filter_map(Result::ok)
|
|
||||||
.collect();
|
|
||||||
Ok(format!(
|
|
||||||
"{}the file is a SQLite database, but not a QuickSearch index \
|
|
||||||
(it holds {}). Refusing to replace it — point [paths] database_path \
|
|
||||||
somewhere else, or move that file away first.",
|
|
||||||
FOREIGN_DB_PREFIX,
|
|
||||||
if names.is_empty() {
|
|
||||||
"tables this program does not recognise".to_string()
|
|
||||||
} else {
|
|
||||||
names.join(", ")
|
|
||||||
}
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schema_version_current(conn: &Connection) -> Result<bool, String> {
|
fn schema_version_current(conn: &Connection) -> Result<bool, String> {
|
||||||
// The shape check rather than a name lookup: a table called `schema_info`
|
let has_info: bool = conn
|
||||||
// with other columns belongs to some other program, and reading `value`
|
.query_row(
|
||||||
// out of it would fail the open with a SQL error instead of the refusal
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_info'",
|
||||||
// the caller can act on.
|
[],
|
||||||
if !has_our_schema_info(conn) {
|
|_| Ok(true),
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| format!("sqlite_master schema_info: {}", e))?
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !has_info {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,17 +298,6 @@ fn schema_version_current(conn: &Connection) -> Result<bool, String> {
|
||||||
/// effective-tokenizer string this caller asked for.
|
/// effective-tokenizer string this caller asked for.
|
||||||
fn db_matches_current(conn: &Connection, tokenizer: &str) -> Result<bool, String> {
|
fn db_matches_current(conn: &Connection, tokenizer: &str) -> Result<bool, String> {
|
||||||
if !schema_version_current(conn)? {
|
if !schema_version_current(conn)? {
|
||||||
// Refuse rather than wipe unless the file is recognisably ours. The
|
|
||||||
// wipe policy is about replacing an index this program wrote under an
|
|
||||||
// older layout, and `database_path` is a free-text field with no
|
|
||||||
// picker and no confirmation — a typo naming some other
|
|
||||||
// application's SQLite file would otherwise delete it, and its `-wal`
|
|
||||||
// and `-shm` with it, on the next indexing run. An older layout of
|
|
||||||
// ours still wipes, and so does a file with no tables at all, which
|
|
||||||
// is ours to create.
|
|
||||||
if !is_ours_or_empty(conn)? {
|
|
||||||
return Err(foreign_database_message(conn)?);
|
|
||||||
}
|
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ fn tokenizer_drift_wipes_db() {
|
||||||
let first_effective = {
|
let first_effective = {
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime) \
|
"INSERT INTO files (name, path, parent, size, mtime) \
|
||||||
VALUES ('x', '/', 0, 0)",
|
VALUES ('x', '/x', '/', 0, 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -164,8 +164,8 @@ fn open_existing_reads_nondefault_tokenizer_without_wiping() {
|
||||||
{
|
{
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "unicode61").unwrap();
|
let conn = open_or_recreate(p.to_str().unwrap(), "unicode61").unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime) \
|
"INSERT INTO files (name, path, parent, size, mtime) \
|
||||||
VALUES ('note', '/', 0, 0)",
|
VALUES ('note', '/note.txt', '/', 0, 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -384,8 +384,8 @@ fn keyed_create_reopen_and_header_is_encrypted() {
|
||||||
{
|
{
|
||||||
let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap();
|
let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime) \
|
"INSERT INTO files (name, path, parent, size, mtime) \
|
||||||
VALUES ('secret', '/', 0, 0)",
|
VALUES ('secret', '/secret.txt', '/', 0, 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -418,8 +418,8 @@ fn wrong_key_errors_without_wiping() {
|
||||||
let conn =
|
let conn =
|
||||||
open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&test_key(0xa1))).unwrap();
|
open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&test_key(0xa1))).unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime) \
|
"INSERT INTO files (name, path, parent, size, mtime) \
|
||||||
VALUES ('x', '/', 0, 0)",
|
VALUES ('x', '/x', '/', 0, 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -532,15 +532,15 @@ fn open_existing_rw_allows_delete() {
|
||||||
{
|
{
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime) \
|
"INSERT INTO files (name, path, parent, size, mtime) \
|
||||||
VALUES ('a', '/', 0, 0)",
|
VALUES ('a', '/a', '/', 0, 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
let conn = open_existing(p.to_str().unwrap(), true).unwrap();
|
let conn = open_existing(p.to_str().unwrap(), true).unwrap();
|
||||||
let removed = conn
|
let removed = conn
|
||||||
.execute("DELETE FROM files WHERE parent = '/' AND name = 'a'", [])
|
.execute("DELETE FROM files WHERE path = '/a'", [])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(removed, 1);
|
assert_eq!(removed, 1);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
@ -571,8 +571,8 @@ fn a_fresh_index_and_its_sidecars_are_owner_only() {
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
// A write, so the WAL and SHM exist to be checked.
|
// A write, so the WAL and SHM exist to be checked.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime) \
|
"INSERT INTO files (name, path, parent, size, mtime) \
|
||||||
VALUES ('a', '/', 0, 0)",
|
VALUES ('a', '/perm-a', '/', 0, 0)",
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -598,167 +598,3 @@ fn a_fresh_index_and_its_sidecars_are_owner_only() {
|
||||||
drop(conn);
|
drop(conn);
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `maintain` must work on a *keyed* index, which is the one case the pragma
|
|
||||||
/// it reads does not answer with an integer.
|
|
||||||
///
|
|
||||||
/// SQLCipher intercepts `PRAGMA page_size` on a keyed connection, answers with
|
|
||||||
/// `cipher_page_size`, and hands that back as TEXT. Reading it straight into an
|
|
||||||
/// `i64` failed there and only there — so every unencrypted test passed while
|
|
||||||
/// every index with a password set silently skipped both its VACUUM and its
|
|
||||||
/// `PRAGMA optimize`. The assertion is simply that the call succeeds: it has to
|
|
||||||
/// get past all three pragma reads to return at all.
|
|
||||||
#[test]
|
|
||||||
fn maintain_reads_its_pragmas_on_a_keyed_index() {
|
|
||||||
let p = tmp_db_path();
|
|
||||||
let key = test_key(0xc3);
|
|
||||||
let dir = p.parent().unwrap().to_string_lossy().into_owned();
|
|
||||||
{
|
|
||||||
let conn = open_or_recreate_keyed(p.to_str().unwrap(), "trigram", Some(&key)).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO files (name, parent, size, mtime) VALUES ('x', '/', 0, 0)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let conn = open_keyed_with_pragmas(p.to_str().unwrap(), true, Some(&key), PRAGMAS_MAINTENANCE)
|
|
||||||
.unwrap();
|
|
||||||
// A two-row index has no slack worth reclaiming, so `false` is the
|
|
||||||
// expected answer — what matters is that it is an answer and not an error.
|
|
||||||
assert_eq!(
|
|
||||||
crate::db::repo::maintain(&conn, &dir),
|
|
||||||
Ok(false),
|
|
||||||
"maintain must not fail on a keyed index"
|
|
||||||
);
|
|
||||||
|
|
||||||
// And the value itself has to be usable, not merely readable: a page size
|
|
||||||
// that parsed as 0 would size the free-space check at zero bytes and wave
|
|
||||||
// through a VACUUM that cannot fit.
|
|
||||||
assert!(
|
|
||||||
crate::db::repo::pragma_number(&conn, "page_size").unwrap() >= 512,
|
|
||||||
"a real page size, not a silent zero"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `database_path` is a free-text field in Settings with no picker and no
|
|
||||||
/// confirmation. A typo naming another application's database used to delete
|
|
||||||
/// it — and its `-wal` and `-shm` — on the next indexing run, because "no
|
|
||||||
/// `schema_info` table" was read as "an old index of ours".
|
|
||||||
#[test]
|
|
||||||
fn a_foreign_database_is_refused_not_wiped() {
|
|
||||||
let p = tmp_db_path();
|
|
||||||
{
|
|
||||||
let conn = Connection::open(&p).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE moz_places (id INTEGER PRIMARY KEY, url TEXT)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO moz_places(url) VALUES('https://example.invalid/')",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let err = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap_err();
|
|
||||||
assert!(err.starts_with(FOREIGN_DB_PREFIX), "got: {err}");
|
|
||||||
assert!(
|
|
||||||
err.contains("moz_places"),
|
|
||||||
"the message must name what is in the way: {err}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The row is still there — the point of the whole exercise.
|
|
||||||
let conn = Connection::open(&p).unwrap();
|
|
||||||
let url: String = conn
|
|
||||||
.query_row("SELECT url FROM moz_places", [], |r| r.get(0))
|
|
||||||
.expect("the foreign database must survive intact");
|
|
||||||
assert_eq!(url, "https://example.invalid/");
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The refusal must not extend to a file that is genuinely ours to create:
|
|
||||||
/// an empty database is what SQLite leaves behind for a path nothing has
|
|
||||||
/// written yet.
|
|
||||||
#[test]
|
|
||||||
fn an_empty_database_file_is_still_ours_to_build() {
|
|
||||||
let p = tmp_db_path();
|
|
||||||
// An empty but real SQLite file, header and all.
|
|
||||||
drop(Connection::open(&p).unwrap());
|
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
let v: String = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT value FROM schema_info WHERE key='version'",
|
|
||||||
[],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(v, CURRENT_SCHEMA_VERSION.to_string());
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Each pre-`schema_info` table name marks a file as ours, and so is wiped
|
|
||||||
/// rather than refused — the legacy-layout policy depends on exactly that.
|
|
||||||
#[test]
|
|
||||||
fn a_legacy_table_marks_a_file_as_ours() {
|
|
||||||
for table in LEGACY_TABLES {
|
|
||||||
let p = tmp_db_path();
|
|
||||||
{
|
|
||||||
let conn = Connection::open(&p).unwrap();
|
|
||||||
conn.execute(&format!("CREATE TABLE {} (x INTEGER)", table), [])
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram")
|
|
||||||
.unwrap_or_else(|e| panic!("{table} should read as ours, got: {e}"));
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `schema_info` with our columns but no version row is an index whose
|
|
||||||
/// creation was interrupted. It is ours, and rebuilding it is right.
|
|
||||||
#[test]
|
|
||||||
fn a_schema_info_without_a_version_row_is_still_ours() {
|
|
||||||
let p = tmp_db_path();
|
|
||||||
{
|
|
||||||
let conn = Connection::open(&p).unwrap();
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE schema_info (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").expect("half-built index is ours");
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A table that merely *shares the name* `schema_info` is not ours. Matching
|
|
||||||
/// on the name alone used to fail the open with a raw SQL error about a
|
|
||||||
/// missing column, which is neither a refusal the caller can act on nor a
|
|
||||||
/// message anyone could act on either.
|
|
||||||
#[test]
|
|
||||||
fn a_foreign_schema_info_is_refused() {
|
|
||||||
let p = tmp_db_path();
|
|
||||||
{
|
|
||||||
let conn = Connection::open(&p).unwrap();
|
|
||||||
conn.execute("CREATE TABLE schema_info (revision INTEGER)", [])
|
|
||||||
.unwrap();
|
|
||||||
conn.execute("INSERT INTO schema_info(revision) VALUES(3)", [])
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
let err = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap_err();
|
|
||||||
assert!(err.starts_with(FOREIGN_DB_PREFIX), "got: {err}");
|
|
||||||
|
|
||||||
let conn = Connection::open(&p).unwrap();
|
|
||||||
let revision: i64 = conn
|
|
||||||
.query_row("SELECT revision FROM schema_info", [], |r| r.get(0))
|
|
||||||
.expect("the foreign database must survive intact");
|
|
||||||
assert_eq!(revision, 3);
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -62,8 +62,7 @@ fn set_state_clearing_failure(
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NewFile<'a> {
|
pub struct NewFile<'a> {
|
||||||
pub name: &'a str,
|
pub name: &'a str,
|
||||||
/// The containing directory, ending in the platform separator — see
|
pub path: &'a str,
|
||||||
/// [`crate::file_handling::split_db_path`], which produces the pair.
|
|
||||||
pub parent: &'a str,
|
pub parent: &'a str,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub mtime: u64,
|
pub mtime: u64,
|
||||||
|
|
@ -75,29 +74,21 @@ pub struct NewFile<'a> {
|
||||||
pub needs_content: bool,
|
pub needs_content: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NewFile<'_> {
|
|
||||||
/// The file's path, for a log or error message. Not stored; see
|
|
||||||
/// [`super::schema::SCHEMA_CURRENT`].
|
|
||||||
fn path(&self) -> String {
|
|
||||||
format!("{}{}", self.parent, self.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Insert a new file row, returning its id. `content_state` comes from
|
/// Insert a new file row, returning its id. `content_state` comes from
|
||||||
/// `needs_content`; there is no separate basic state, because the row
|
/// `needs_content`; there is no separate basic state, because the row
|
||||||
/// existing *is* the basic-index state. `INSERT OR IGNORE`: a
|
/// existing *is* the basic-index state. `INSERT OR IGNORE`: a UNIQUE(path)
|
||||||
/// `UNIQUE(parent, name)` collision returns `None` rather than aborting the
|
/// collision returns `None` rather than aborting the batch.
|
||||||
/// batch.
|
|
||||||
pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
|
pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
|
||||||
let rows = tx
|
let rows = tx
|
||||||
.prepare_cached(
|
.prepare_cached(
|
||||||
"INSERT OR IGNORE INTO files (
|
"INSERT OR IGNORE INTO files (
|
||||||
name, parent, size, mtime, mime, type, content_state, hash
|
name, path, parent, size, mtime, mime, type, content_state, hash
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||||
)
|
)
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| {
|
||||||
stmt.execute(params![
|
stmt.execute(params![
|
||||||
f.name,
|
f.name,
|
||||||
|
f.path,
|
||||||
f.parent,
|
f.parent,
|
||||||
f.size as i64,
|
f.size as i64,
|
||||||
f.mtime as i64,
|
f.mtime as i64,
|
||||||
|
|
@ -107,7 +98,7 @@ pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>,
|
||||||
f.hash,
|
f.hash,
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("insert file {}: {}", f.path(), e))?;
|
.map_err(|e| format!("insert file {}: {}", f.path, e))?;
|
||||||
if rows == 0 {
|
if rows == 0 {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
@ -129,14 +120,14 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 {
|
||||||
/// reset its content state from `f.needs_content`, clearing any extracted
|
/// reset its content state from `f.needs_content`, clearing any extracted
|
||||||
/// content so the text-indexing pass re-processes it. Writes `size`, `mtime`,
|
/// content so the text-indexing pass re-processes it. Writes `size`, `mtime`,
|
||||||
/// `hash`, `mime`, `type` and `content_state` — and only those; `name` and
|
/// `hash`, `mime`, `type` and `content_state` — and only those; `name` and
|
||||||
/// `parent` are the key it matches on, so they cannot change here.
|
/// `parent` are not refreshed here.
|
||||||
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
|
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
|
||||||
let id: Option<i64> = tx
|
let id: Option<i64> = tx
|
||||||
.prepare_cached(
|
.prepare_cached(
|
||||||
"UPDATE files
|
"UPDATE files
|
||||||
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
|
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
|
||||||
content_state = ?6
|
content_state = ?6
|
||||||
WHERE parent = ?7 AND name = ?8
|
WHERE path = ?7
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
)
|
)
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| {
|
||||||
|
|
@ -148,14 +139,13 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
|
||||||
f.mime,
|
f.mime,
|
||||||
f.ftype.bits() as i64,
|
f.ftype.bits() as i64,
|
||||||
initial_content_state(f),
|
initial_content_state(f),
|
||||||
f.parent,
|
f.path,
|
||||||
f.name,
|
|
||||||
],
|
],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("update file {}: {}", f.path(), e))?;
|
.map_err(|e| format!("update file {}: {}", f.path, e))?;
|
||||||
let Some(id) = id else {
|
let Some(id) = id else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|
@ -184,38 +174,7 @@ pub fn set_content_done(
|
||||||
text_zstd: Option<&[u8]>,
|
text_zstd: Option<&[u8]>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
remove_content_for_id(tx, file_id)?;
|
remove_content_for_id(tx, file_id)?;
|
||||||
set_content_done_fresh(tx, file_id, text, text_zstd)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`set_content_done`] for a row that **provably holds no content yet**,
|
|
||||||
/// skipping the pre-delete.
|
|
||||||
///
|
|
||||||
/// The two statements `remove_content_for_id` issues are not free: one is a
|
|
||||||
/// tombstoning delete on a contentless FTS5 table, and both run inside the
|
|
||||||
/// writer's transaction with the shared connection held. For a row that cannot
|
|
||||||
/// have content they are pure overhead, and the indexer hit them on nearly
|
|
||||||
/// every file it wrote:
|
|
||||||
///
|
|
||||||
/// * a row `insert_file` has just created has no `searchabletext` or
|
|
||||||
/// `documents_text` row by construction;
|
|
||||||
/// * a row [`update_file_basic`] has just written has had its content removed
|
|
||||||
/// **by that call**, so `set_content_done` was deleting it a second time.
|
|
||||||
///
|
|
||||||
/// Both paths run for every file small enough for the walk to extract inline
|
|
||||||
/// (`hash_length`, 8 KiB by default), which on a source tree is most of it.
|
|
||||||
///
|
|
||||||
/// Use [`set_content_done`] wherever the row's prior state is not known — the
|
|
||||||
/// content pass writing a row that may have been extracted before, the watcher,
|
|
||||||
/// anything reached from a config reconcile. Getting this wrong leaves a
|
|
||||||
/// duplicate FTS entry rather than a visible error, so the rule is: skip the
|
|
||||||
/// delete only where the *same transaction* has already established there is
|
|
||||||
/// nothing there.
|
|
||||||
pub fn set_content_done_fresh(
|
|
||||||
tx: &Transaction<'_>,
|
|
||||||
file_id: i64,
|
|
||||||
text: &str,
|
|
||||||
text_zstd: Option<&[u8]>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
// Contentless FTS5 still accepts values on INSERT — the tokenizer needs
|
// Contentless FTS5 still accepts values on INSERT — the tokenizer needs
|
||||||
// them — it simply doesn't persist the raw column values.
|
// them — it simply doesn't persist the raw column values.
|
||||||
exec(
|
exec(
|
||||||
|
|
@ -296,15 +255,7 @@ impl DocDecoder {
|
||||||
// settles at the largest document in the scan within the first few
|
// settles at the largest document in the scan within the first few
|
||||||
// rows, after which decoding a row allocates nothing at all.
|
// rows, after which decoding a row allocates nothing at all.
|
||||||
if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(blob) {
|
if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(blob) {
|
||||||
// Clamped: `size` is a `u64` read straight out of the frame
|
self.buf.reserve(usize::try_from(size).ok()?);
|
||||||
// header, so a corrupt or hostile blob can ask for terabytes here
|
|
||||||
// and `reserve` answers an impossible request by aborting the
|
|
||||||
// process, not by failing. The doubling loop below already treats
|
|
||||||
// `MAX_DOC_CAPACITY` as the point past which a frame is corrupt
|
|
||||||
// rather than merely large, so a clamped reservation that then
|
|
||||||
// fails to decompress returns `None` through the same path.
|
|
||||||
self.buf
|
|
||||||
.reserve(usize::try_from(size).ok()?.min(MAX_DOC_CAPACITY));
|
|
||||||
}
|
}
|
||||||
loop {
|
loop {
|
||||||
if self.buf.capacity() == 0 {
|
if self.buf.capacity() == 0 {
|
||||||
|
|
@ -414,38 +365,29 @@ pub fn set_content_na(tx: &Transaction<'_>, file_id: i64) -> Result<(), String>
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete a file row by path, keeping FTS in sync. Returns whether a row was
|
/// Delete a file row by path, keeping FTS in sync. Returns whether a row was
|
||||||
/// removed — including `false` for a string that cannot be a file's path at
|
/// removed.
|
||||||
/// all, which is not in the index by construction.
|
|
||||||
pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, String> {
|
pub fn delete_file_by_path(tx: &Transaction<'_>, path: &str) -> Result<bool, String> {
|
||||||
let Some((parent, name)) = crate::file_handling::split_db_path(path) else {
|
|
||||||
return Ok(false);
|
|
||||||
};
|
|
||||||
let id: Option<i64> = tx
|
let id: Option<i64> = tx
|
||||||
.prepare_cached("DELETE FROM files WHERE parent = ?1 AND name = ?2 RETURNING id")
|
.prepare_cached("DELETE FROM files WHERE path = ?1 RETURNING id")
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| stmt.query_row(params![path], |r| r.get(0)).optional())
|
||||||
stmt.query_row(params![parent, name], |r| r.get(0))
|
|
||||||
.optional()
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("delete file {}: {}", path, e))?;
|
.map_err(|e| format!("delete file {}: {}", path, e))?;
|
||||||
let Some(id) = id else { return Ok(false) };
|
let Some(id) = id else { return Ok(false) };
|
||||||
remove_content_for_id(tx, id)?;
|
remove_content_for_id(tx, id)?;
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete every row whose parent falls in the half-open range `[lo, hi)`,
|
/// Delete every row whose path falls in the half-open range `[lo, hi)`,
|
||||||
/// keeping the dependent tables in step. Returns how many `files` rows went.
|
/// keeping the dependent tables in step. Returns how many `files` rows went.
|
||||||
///
|
///
|
||||||
/// Four statements regardless of how many files the range holds, and the
|
/// Five statements regardless of how many files the range holds, and the
|
||||||
/// range is an index seek on `UNIQUE(files.parent, files.name)`. Build the
|
/// range is an index seek on `UNIQUE(files.path)`. Build the bounds with
|
||||||
/// bounds with [`crate::file_handling::ExtractCursor::for_root`], which is
|
/// [`crate::file_handling::ExtractCursor::for_root`], which is what makes
|
||||||
/// what makes them separator-correct — and note the range covers the root's
|
/// them separator-correct.
|
||||||
/// *own* files only because every stored parent ends in a separator (see
|
|
||||||
/// `dir_to_db_parent`).
|
|
||||||
pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize, String> {
|
pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize, String> {
|
||||||
for (table, key) in DEPENDENT_TABLES {
|
for (table, key) in DEPENDENT_TABLES {
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"DELETE FROM {} WHERE {} IN \
|
"DELETE FROM {} WHERE {} IN \
|
||||||
(SELECT id FROM files WHERE parent >= ?1 AND parent < ?2)",
|
(SELECT id FROM files WHERE path >= ?1 AND path < ?2)",
|
||||||
table, key
|
table, key
|
||||||
);
|
);
|
||||||
exec(tx, &sql, params![lo, hi], || {
|
exec(tx, &sql, params![lo, hi], || {
|
||||||
|
|
@ -454,13 +396,13 @@ pub fn delete_subtree(tx: &Transaction<'_>, lo: &str, hi: &str) -> Result<usize,
|
||||||
}
|
}
|
||||||
exec(
|
exec(
|
||||||
tx,
|
tx,
|
||||||
"DELETE FROM files WHERE parent >= ?1 AND parent < ?2",
|
"DELETE FROM files WHERE path >= ?1 AND path < ?2",
|
||||||
params![lo, hi],
|
params![lo, hi],
|
||||||
|| format!("delete files under {}", lo),
|
|| format!("delete files under {}", lo),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete every row whose parent falls in *none* of `ranges`. Returns how many
|
/// Delete every row whose path falls in *none* of `ranges`. Returns how many
|
||||||
/// `files` rows went.
|
/// `files` rows went.
|
||||||
///
|
///
|
||||||
/// A scan of `files` rather than a seek, reserved for the one transition that
|
/// A scan of `files` rather than a seek, reserved for the one transition that
|
||||||
|
|
@ -481,7 +423,7 @@ pub fn delete_outside_ranges(
|
||||||
predicate.push_str(" AND ");
|
predicate.push_str(" AND ");
|
||||||
}
|
}
|
||||||
predicate.push_str(&format!(
|
predicate.push_str(&format!(
|
||||||
"NOT (parent >= ?{} AND parent < ?{})",
|
"NOT (path >= ?{} AND path < ?{})",
|
||||||
i * 2 + 1,
|
i * 2 + 1,
|
||||||
i * 2 + 2
|
i * 2 + 2
|
||||||
));
|
));
|
||||||
|
|
@ -543,12 +485,8 @@ pub fn delete_ids(tx: &Transaction<'_>, ids: &[i64]) -> Result<usize, String> {
|
||||||
Ok(removed)
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every indexed file directly inside `parent`, as `name -> mtime`.
|
/// Every indexed file directly inside `parent`, as `name -> mtime`. Served by
|
||||||
///
|
/// `idx_files_parent`: one index range lookup.
|
||||||
/// `parent` must be in stored spelling — trailing separator and all; build it
|
|
||||||
/// with [`crate::file_handling::dir_to_db_parent`]. One `idx_files_parent`
|
|
||||||
/// range lookup for the names, then a row fetch each for the mtimes; see the
|
|
||||||
/// index's own comment for why that is the shape it is.
|
|
||||||
pub fn dir_rows(
|
pub fn dir_rows(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
parent: &str,
|
parent: &str,
|
||||||
|
|
@ -569,9 +507,7 @@ pub fn dir_rows(
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A row the content pass has yet to extract: `(id, name, path, mime)`. The
|
/// A row the content pass has yet to extract: `(id, name, path, mime)`.
|
||||||
/// path is reassembled here rather than stored — see
|
|
||||||
/// [`super::schema::SCHEMA_CURRENT`] — because the pass opens the file by it.
|
|
||||||
pub type PendingContentRow = (i64, String, String, Option<String>);
|
pub type PendingContentRow = (i64, String, String, Option<String>);
|
||||||
|
|
||||||
/// One page of rows still awaiting content extraction under `cursor`'s range,
|
/// One page of rows still awaiting content extraction under `cursor`'s range,
|
||||||
|
|
@ -590,11 +526,11 @@ pub fn pending_content_page(
|
||||||
.prepare_cached(
|
.prepare_cached(
|
||||||
// `INDEXED BY` rather than a hint, because the planner gets this
|
// `INDEXED BY` rather than a hint, because the planner gets this
|
||||||
// one wrong exactly when it costs most. Left to itself it takes
|
// one wrong exactly when it costs most. Left to itself it takes
|
||||||
// `idx_files_parent` for the range and then sorts the survivors
|
// `UNIQUE(path)` for the range and then sorts the survivors into a
|
||||||
// into a temp b-tree to satisfy `ORDER BY id` — which means every
|
// temp b-tree to satisfy `ORDER BY id` — which means every page
|
||||||
// page walks the whole root's range and fetches each row's heap
|
// walks the whole root's range and fetches each row's heap entry
|
||||||
// entry to test `content_state`. At `FEED_PAGE` rows per page that
|
// to test `content_state`. At `FEED_PAGE` rows per page that is
|
||||||
// is quadratic over a run. The partial index below is already
|
// quadratic over a run. The partial index below is already
|
||||||
// id-ordered, so it answers `id > ?` and the ORDER BY together and
|
// id-ordered, so it answers `id > ?` and the ORDER BY together and
|
||||||
// holds only pending rows. Measured on 500k rows with everything
|
// holds only pending rows. Measured on 500k rows with everything
|
||||||
// pending — the first index of a tree, i.e. the case that matters:
|
// pending — the first index of a tree, i.e. the case that matters:
|
||||||
|
|
@ -603,9 +539,9 @@ pub fn pending_content_page(
|
||||||
// The planner only prefers it once pending rows are a small
|
// The planner only prefers it once pending rows are a small
|
||||||
// minority, and never before ANALYZE has run at all, which is why
|
// minority, and never before ANALYZE has run at all, which is why
|
||||||
// this cannot be left to statistics.
|
// this cannot be left to statistics.
|
||||||
"SELECT id, parent, name, mime FROM files INDEXED BY idx_files_content_pending
|
"SELECT id, name, path, mime FROM files INDEXED BY idx_files_content_pending
|
||||||
WHERE content_state = 0 AND size <= ?1 AND id > ?2
|
WHERE content_state = 0 AND size <= ?1 AND id > ?2
|
||||||
AND parent >= ?3 AND parent < ?4
|
AND path >= ?3 AND path < ?4
|
||||||
ORDER BY id
|
ORDER BY id
|
||||||
LIMIT ?5",
|
LIMIT ?5",
|
||||||
)
|
)
|
||||||
|
|
@ -614,13 +550,10 @@ pub fn pending_content_page(
|
||||||
.query_map(
|
.query_map(
|
||||||
params![max_size, cursor.last_id, cursor.lo, cursor.hi, limit],
|
params![max_size, cursor.last_id, cursor.lo, cursor.hi, limit],
|
||||||
|row| {
|
|row| {
|
||||||
let parent: String = row.get(1)?;
|
|
||||||
let name: String = row.get(2)?;
|
|
||||||
let path = format!("{}{}", parent, name);
|
|
||||||
Ok((
|
Ok((
|
||||||
row.get::<_, i64>(0)?,
|
row.get::<_, i64>(0)?,
|
||||||
name,
|
row.get::<_, String>(1)?,
|
||||||
path,
|
row.get::<_, String>(2)?,
|
||||||
row.get::<_, Option<String>>(3)?,
|
row.get::<_, Option<String>>(3)?,
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
|
|
@ -632,16 +565,10 @@ pub fn pending_content_page(
|
||||||
|
|
||||||
/// A stored row as the scope reconciler sees it: enough to decide both
|
/// A stored row as the scope reconciler sees it: enough to decide both
|
||||||
/// whether the path is still in scope and whether its content still is.
|
/// whether the path is still in scope and whether its content still is.
|
||||||
///
|
|
||||||
/// `path` is `parent` and `name` joined, kept alongside them because the
|
|
||||||
/// reconciler tests it as a [`std::path::Path`] while the cursor resumes from
|
|
||||||
/// the key.
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ScopeRow {
|
pub struct ScopeRow {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub parent: String,
|
|
||||||
pub name: String,
|
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub mime: Option<String>,
|
pub mime: Option<String>,
|
||||||
pub content_state: i64,
|
pub content_state: i64,
|
||||||
|
|
@ -663,7 +590,7 @@ pub struct RootCounts {
|
||||||
pub fts: i64,
|
pub fts: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Count the rows in the half-open parent range `[lo, hi)` and, in the same
|
/// Count the rows in the half-open path range `[lo, hi)` and, in the same
|
||||||
/// pass, how many of them have a full-text row.
|
/// pass, how many of them have a full-text row.
|
||||||
///
|
///
|
||||||
/// `content_state = STATE_DONE` *is* "has a `searchabletext` row":
|
/// `content_state = STATE_DONE` *is* "has a `searchabletext` row":
|
||||||
|
|
@ -673,13 +600,13 @@ pub struct RootCounts {
|
||||||
/// contentless and keyed by `rowid`, so it has no path to range-scan on.
|
/// contentless and keyed by `rowid`, so it has no path to range-scan on.
|
||||||
///
|
///
|
||||||
/// One statement, but not a cheap one: `content_state` is not carried by the
|
/// One statement, but not a cheap one: `content_state` is not carried by the
|
||||||
/// `idx_files_parent` index the range seeks on, so every row in the range is
|
/// `UNIQUE(files.path)` index the range seeks on, so every row in the range is
|
||||||
/// fetched. Call it where a run has just read those rows anyway, not on a
|
/// fetched. Call it where a run has just read those rows anyway, not on a
|
||||||
/// cadence.
|
/// cadence.
|
||||||
pub fn count_root(conn: &Connection, lo: &str, hi: &str) -> Result<RootCounts, String> {
|
pub fn count_root(conn: &Connection, lo: &str, hi: &str) -> Result<RootCounts, String> {
|
||||||
conn.prepare_cached(
|
conn.prepare_cached(
|
||||||
"SELECT COUNT(*), COALESCE(SUM(content_state = ?3), 0) FROM files
|
"SELECT COUNT(*), COALESCE(SUM(content_state = ?3), 0) FROM files
|
||||||
WHERE parent >= ?1 AND parent < ?2",
|
WHERE path >= ?1 AND path < ?2",
|
||||||
)
|
)
|
||||||
.and_then(|mut stmt| {
|
.and_then(|mut stmt| {
|
||||||
stmt.query_row(params![lo, hi, STATE_DONE], |r| {
|
stmt.query_row(params![lo, hi, STATE_DONE], |r| {
|
||||||
|
|
@ -692,46 +619,37 @@ pub fn count_root(conn: &Connection, lo: &str, hi: &str) -> Result<RootCounts, S
|
||||||
.map_err(|e| format!("count root {}: {}", lo, e))
|
.map_err(|e| format!("count root {}: {}", lo, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One page of rows sorting after `(after_parent, after_name)` and inside the
|
/// One page of rows whose path is `> after` and `< hi`, in path order.
|
||||||
/// parent range ending at `hi`, in `(parent, name)` order.
|
|
||||||
///
|
///
|
||||||
/// Keyset on the `idx_files_parent` key itself: every page is an index walk
|
/// Keyset on `path`: every page is an index walk with no sort step, and a row
|
||||||
/// with no sort step, and a row is served at most once even though the caller
|
/// is served at most once even though the caller is deleting behind the
|
||||||
/// is deleting behind the reader. Seed the cursor with `(lo, "")` — no name is
|
/// reader. Seed `after` with the range's `lo` bound, which is
|
||||||
/// empty, so that lands exactly on the first row of the range.
|
/// `root + separator` and so can never equal a stored path.
|
||||||
///
|
|
||||||
/// The row-value comparison is what keeps it one seek; spelled out as
|
|
||||||
/// `parent > ? OR (parent = ? AND name > ?)` the planner is free to scan.
|
|
||||||
pub fn rows_in_range_page(
|
pub fn rows_in_range_page(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
after_parent: &str,
|
after: &str,
|
||||||
after_name: &str,
|
|
||||||
hi: &str,
|
hi: &str,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<ScopeRow>, String> {
|
) -> Result<Vec<ScopeRow>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare_cached(
|
.prepare_cached(
|
||||||
"SELECT id, parent, name, size, mime, content_state FROM files
|
"SELECT id, path, size, mime, content_state FROM files
|
||||||
WHERE (parent, name) > (?1, ?2) AND parent < ?3
|
WHERE path > ?1 AND path < ?2
|
||||||
ORDER BY parent, name
|
ORDER BY path
|
||||||
LIMIT ?4",
|
LIMIT ?3",
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("prepare range page: {}", e))?;
|
.map_err(|e| format!("prepare range page: {}", e))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![after_parent, after_name, hi, limit], |row| {
|
.query_map(params![after, hi, limit], |row| {
|
||||||
let parent: String = row.get(1)?;
|
|
||||||
let name: String = row.get(2)?;
|
|
||||||
Ok(ScopeRow {
|
Ok(ScopeRow {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
path: format!("{}{}", parent, name),
|
path: row.get(1)?,
|
||||||
name,
|
size: row.get::<_, i64>(2)?.max(0) as u64,
|
||||||
parent,
|
mime: row.get(3)?,
|
||||||
size: row.get::<_, i64>(3)?.max(0) as u64,
|
content_state: row.get(4)?,
|
||||||
mime: row.get(4)?,
|
|
||||||
content_state: row.get(5)?,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| format!("query range page after {}: {}", after_parent, e))?;
|
.map_err(|e| format!("query range page after {}: {}", after, e))?;
|
||||||
rows.collect::<Result<Vec<_>, _>>()
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(|e| format!("read range page row: {}", e))
|
.map_err(|e| format!("read range page row: {}", e))
|
||||||
}
|
}
|
||||||
|
|
@ -765,26 +683,18 @@ pub fn reset_content_pending(tx: &Transaction<'_>, file_id: i64) -> Result<(), S
|
||||||
/// files whose parent isn't the directory being read (a resolved symlink
|
/// files whose parent isn't the directory being read (a resolved symlink
|
||||||
/// target), where [`dir_rows`] would not have them.
|
/// target), where [`dir_rows`] would not have them.
|
||||||
pub fn mtime_for_path(conn: &Connection, path: &str) -> Result<Option<u64>, String> {
|
pub fn mtime_for_path(conn: &Connection, path: &str) -> Result<Option<u64>, String> {
|
||||||
let Some((parent, name)) = crate::file_handling::split_db_path(path) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare_cached("SELECT mtime FROM files WHERE parent = ?1 AND name = ?2")
|
.prepare_cached("SELECT mtime FROM files WHERE path = ?1")
|
||||||
.map_err(|e| format!("prepare mtime lookup for {}: {}", path, e))?;
|
.map_err(|e| format!("prepare mtime lookup for {}: {}", path, e))?;
|
||||||
stmt.query_row(params![parent, name], |r| r.get::<_, i64>(0))
|
stmt.query_row(params![path], |r| r.get::<_, i64>(0))
|
||||||
.optional()
|
.optional()
|
||||||
.map(|o| o.map(|m| m.max(0) as u64))
|
.map(|o| o.map(|m| m.max(0) as u64))
|
||||||
.map_err(|e| format!("mtime lookup for {}: {}", path, e))
|
.map_err(|e| format!("mtime lookup for {}: {}", path, e))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Distinct `parent` values within the half-open range `[lo, hi)`, streamed to
|
/// Distinct `parent` values within the half-open path range `[lo, hi)`,
|
||||||
/// `f` so nothing proportional to the tree is materialized. `idx_files_parent`
|
/// streamed to `f` so nothing proportional to the tree is materialized.
|
||||||
/// makes this an index-only scan.
|
/// `idx_files_parent` makes this an index-only scan.
|
||||||
///
|
|
||||||
/// The root's own directory is included: its stored parent is `root + SEP`,
|
|
||||||
/// which is exactly `lo`. It was not, back when the same bounds were applied
|
|
||||||
/// to a `path` column and the root's parent was spelled without the trailing
|
|
||||||
/// separator.
|
|
||||||
pub fn for_each_parent_in_range<F: FnMut(String)>(
|
pub fn for_each_parent_in_range<F: FnMut(String)>(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
lo: &str,
|
lo: &str,
|
||||||
|
|
@ -803,17 +713,13 @@ pub fn for_each_parent_in_range<F: FnMut(String)>(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paths of every file directly inside `parent`, which must carry its trailing
|
/// Paths of every file directly inside `parent`.
|
||||||
/// separator — so the join below is a concatenation.
|
|
||||||
pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result<Vec<String>, String> {
|
pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result<Vec<String>, String> {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare_cached("SELECT name FROM files WHERE parent = ?1")
|
.prepare_cached("SELECT path FROM files WHERE parent = ?1")
|
||||||
.map_err(|e| format!("prepare paths in {}: {}", parent, e))?;
|
.map_err(|e| format!("prepare paths in {}: {}", parent, e))?;
|
||||||
let rows = stmt
|
let rows = stmt
|
||||||
.query_map(params![parent], |r| {
|
.query_map(params![parent], |r| r.get::<_, String>(0))
|
||||||
r.get::<_, String>(0)
|
|
||||||
.map(|name| format!("{}{}", parent, name))
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("query paths in {}: {}", parent, e))?;
|
.map_err(|e| format!("query paths in {}: {}", parent, e))?;
|
||||||
rows.collect::<Result<Vec<_>, _>>()
|
rows.collect::<Result<Vec<_>, _>>()
|
||||||
.map_err(|e| format!("read path under {}: {}", parent, e))
|
.map_err(|e| format!("read path under {}: {}", parent, e))
|
||||||
|
|
@ -889,33 +795,6 @@ pub fn checkpoint_and_close(conn: Connection) {
|
||||||
drop(conn);
|
drop(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read a `PRAGMA` that reports a number.
|
|
||||||
///
|
|
||||||
/// Not simply `r.get::<i64>(0)`, because SQLCipher does not always answer with
|
|
||||||
/// one. On a **keyed** connection it intercepts `PRAGMA page_size`, answers
|
|
||||||
/// with `cipher_page_size` instead, and returns that as TEXT — so asking for an
|
|
||||||
/// integer fails with a type error. Unencrypted it is an INTEGER as usual,
|
|
||||||
/// which is why this only ever broke protected installs, and only in
|
|
||||||
/// [`maintain`]: every index with a password set skipped its VACUUM *and* its
|
|
||||||
/// `PRAGMA optimize` from the moment the free-space check was added.
|
|
||||||
///
|
|
||||||
/// A value that is neither is an error rather than a guess — the callers here
|
|
||||||
/// size a disk-space check with it.
|
|
||||||
pub(super) fn pragma_number(conn: &Connection, pragma: &str) -> Result<i64, String> {
|
|
||||||
use rusqlite::types::ValueRef;
|
|
||||||
conn.query_row(&format!("PRAGMA {}", pragma), [], |r| {
|
|
||||||
Ok(match r.get_ref(0)? {
|
|
||||||
ValueRef::Integer(n) => Some(n),
|
|
||||||
ValueRef::Text(t) => std::str::from_utf8(t)
|
|
||||||
.ok()
|
|
||||||
.and_then(|s| s.trim().parse().ok()),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map_err(|e| format!("read {}: {}", pragma, e))?
|
|
||||||
.ok_or_else(|| format!("read {}: not a number", pragma))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Land the log, reclaim the file's slack, and refresh the query planner's
|
/// Land the log, reclaim the file's slack, and refresh the query planner's
|
||||||
/// statistics. Returns whether it vacuumed.
|
/// statistics. Returns whether it vacuumed.
|
||||||
///
|
///
|
||||||
|
|
@ -943,33 +822,14 @@ pub fn maintain(conn: &Connection, db_dir: &str) -> Result<bool, String> {
|
||||||
crate::log_warn!("{}", e);
|
crate::log_warn!("{}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
let page_count = pragma_number(conn, "page_count")?;
|
let page_count: i64 = conn
|
||||||
let freelist = pragma_number(conn, "freelist_count")?;
|
.query_row("PRAGMA page_count", [], |r| r.get(0))
|
||||||
|
.map_err(|e| format!("read page_count: {}", e))?;
|
||||||
|
let freelist: i64 = conn
|
||||||
|
.query_row("PRAGMA freelist_count", [], |r| r.get(0))
|
||||||
|
.map_err(|e| format!("read freelist_count: {}", e))?;
|
||||||
|
|
||||||
let worth_it = freelist * 100 >= page_count * VACUUM_MIN_SLACK_PERCENT;
|
let vacuumed = freelist * 100 >= page_count * VACUUM_MIN_SLACK_PERCENT;
|
||||||
// The doc comment above puts VACUUM's peak transient need at roughly three
|
|
||||||
// times the index. Checking first turns "the volume filled up mid-rebuild"
|
|
||||||
// into a skipped compaction: a rollback is the *good* outcome there, and
|
|
||||||
// the bad one is that writes to the `-shm` mmap on a full filesystem come
|
|
||||||
// back as SIGBUS rather than as an error — see
|
|
||||||
// `indexing::pipeline::DISK_FLOOR`. Unknown free space is not a reason to
|
|
||||||
// skip.
|
|
||||||
let page_size = pragma_number(conn, "page_size")?;
|
|
||||||
let needed = (page_count.max(0) as u64).saturating_mul(page_size.max(0) as u64) * 3;
|
|
||||||
let room = match crate::platform::available_space(std::path::Path::new(db_dir)) {
|
|
||||||
Some(free) if free < needed => {
|
|
||||||
crate::log_warn!(
|
|
||||||
"skipping VACUUM: it needs about {} MiB free in {} and there is {} MiB",
|
|
||||||
needed / (1024 * 1024),
|
|
||||||
db_dir,
|
|
||||||
free / (1024 * 1024)
|
|
||||||
);
|
|
||||||
false
|
|
||||||
}
|
|
||||||
_ => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
let vacuumed = worth_it && room;
|
|
||||||
if vacuumed {
|
if vacuumed {
|
||||||
// `temp_store_directory` is a deprecated pragma that writes a global,
|
// `temp_store_directory` is a deprecated pragma that writes a global,
|
||||||
// so it is set for the VACUUM and cleared straight after rather than
|
// so it is set for the VACUUM and cleared straight after rather than
|
||||||
|
|
@ -988,52 +848,11 @@ pub fn maintain(conn: &Connection, db_dir: &str) -> Result<bool, String> {
|
||||||
// matter, so it is close to free on a run that changed little.
|
// matter, so it is close to free on a run that changed little.
|
||||||
conn.execute_batch("PRAGMA optimize;")
|
conn.execute_batch("PRAGMA optimize;")
|
||||||
.map_err(|e| format!("optimize: {}", e))?;
|
.map_err(|e| format!("optimize: {}", e))?;
|
||||||
note_optimized(db_dir);
|
|
||||||
|
|
||||||
checkpoint_truncate(conn)?;
|
checkpoint_truncate(conn)?;
|
||||||
Ok(vacuumed)
|
Ok(vacuumed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How many times `PRAGMA optimize` has been accepted by SQLite, per index
|
|
||||||
/// directory.
|
|
||||||
///
|
|
||||||
/// Keyed by directory rather than counted globally because the test binaries
|
|
||||||
/// run their `#[test]` functions on concurrent threads against separate scratch
|
|
||||||
/// indexes; a single counter would let one test's maintenance satisfy another
|
|
||||||
/// test's assertion. Bounded by the number of distinct indexes a process opens,
|
|
||||||
/// which outside the tests is one.
|
|
||||||
static OPTIMIZED: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, u64>>> =
|
|
||||||
std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
|
||||||
|
|
||||||
/// Record that the pragma above returned `Ok` for `db_dir`.
|
|
||||||
fn note_optimized(db_dir: &str) {
|
|
||||||
*crate::lock_ok(&OPTIMIZED)
|
|
||||||
.entry(db_dir.to_string())
|
|
||||||
.or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many times the index in `db_dir` has had `PRAGMA optimize` run against
|
|
||||||
/// it in this process.
|
|
||||||
///
|
|
||||||
/// Exists so a test can ask *whether the optimize pass happened* rather than
|
|
||||||
/// trying to catch [`crate::indexing::IndexingStatus::Optimizing`] as it goes
|
|
||||||
/// past. That distinction is the whole point: the status is a sample of a
|
|
||||||
/// transient state, and the faster the pass gets the less often a poller sees
|
|
||||||
/// it — so a test written that way fails more as the code improves, which is
|
|
||||||
/// exactly backwards. This is a latch, so it cannot be missed however quickly
|
|
||||||
/// the pass runs.
|
|
||||||
///
|
|
||||||
/// It records that the statement was handed to SQLite and accepted, not what
|
|
||||||
/// SQLite then chose to do with it — `PRAGMA optimize` is deliberately a no-op
|
|
||||||
/// when no table has drifted far enough to be worth re-analysing, so there is
|
|
||||||
/// no observable side effect to assert on instead.
|
|
||||||
pub fn optimize_count(db_dir: &str) -> u64 {
|
|
||||||
crate::lock_ok(&OPTIMIZED)
|
|
||||||
.get(db_dir)
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read the `last_full_index` marker (unix seconds of the last *successful*
|
/// Read the `last_full_index` marker (unix seconds of the last *successful*
|
||||||
/// full indexing run) from `schema_info`. Absent key — fresh DB, or a DB
|
/// full indexing run) from `schema_info`. Absent key — fresh DB, or a DB
|
||||||
/// from before this marker existed — means "never".
|
/// from before this marker existed — means "never".
|
||||||
|
|
|
||||||
|
|
@ -9,77 +9,6 @@ fn tmp_path() -> std::path::PathBuf {
|
||||||
crate::testutil::scratch_dir("repo").join("index.sqlite")
|
crate::testutil::scratch_dir("repo").join("index.sqlite")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `set_content_done_fresh` skips the pre-delete, so the whole of its safety is
|
|
||||||
/// the caller's claim that the row is clean. This pins both halves of that:
|
|
||||||
/// the fast path leaves exactly one FTS row where it is used correctly, and the
|
|
||||||
/// ordinary entry point still repairs a row that *does* carry content.
|
|
||||||
///
|
|
||||||
/// A duplicate `searchabletext` row is the failure this guards. It surfaces as
|
|
||||||
/// a file appearing twice in full-text results, not as an error, so nothing
|
|
||||||
/// else in the suite would necessarily notice.
|
|
||||||
#[test]
|
|
||||||
fn the_fresh_content_write_leaves_exactly_one_fts_row() {
|
|
||||||
fn new_file(name: &str, mtime: u64) -> NewFile<'_> {
|
|
||||||
NewFile {
|
|
||||||
name,
|
|
||||||
parent: "/d/",
|
|
||||||
size: 10,
|
|
||||||
mtime,
|
|
||||||
mime: Some("text/plain"),
|
|
||||||
ftype: crate::mime::FileType::TEXT,
|
|
||||||
hash: None,
|
|
||||||
needs_content: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn count(conn: &rusqlite::Connection, sql: &str) -> i64 {
|
|
||||||
conn.query_row(sql, [], |r| r.get(0)).unwrap()
|
|
||||||
}
|
|
||||||
fn matches(conn: &rusqlite::Connection, term: &str) -> i64 {
|
|
||||||
conn.query_row(
|
|
||||||
"SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH ?1",
|
|
||||||
[term],
|
|
||||||
|r| r.get(0),
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = tmp_path();
|
|
||||||
let mut conn = open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
|
|
||||||
let tx = conn.transaction().unwrap();
|
|
||||||
// The insert path: a row that has never had content.
|
|
||||||
let id = insert_file(&tx, &new_file("a.txt", 1)).unwrap().unwrap();
|
|
||||||
set_content_done_fresh(&tx, id, "sphinx quartz", zstd_of("sphinx quartz").as_deref()).unwrap();
|
|
||||||
|
|
||||||
// The update path: `update_file_basic` clears the content, so the fresh
|
|
||||||
// write that follows is writing into an empty slot — the exact sequence
|
|
||||||
// `process_batch_updates` performs.
|
|
||||||
let same = update_file_basic(&tx, &new_file("a.txt", 2)).unwrap().unwrap();
|
|
||||||
assert_eq!(same, id, "the same row");
|
|
||||||
set_content_done_fresh(&tx, id, "sphinx onyx", zstd_of("sphinx onyx").as_deref()).unwrap();
|
|
||||||
tx.commit().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM searchabletext"), 1);
|
|
||||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM documents_text"), 1);
|
|
||||||
assert_eq!(matches(&conn, "onyx"), 1, "the new body is searchable");
|
|
||||||
assert_eq!(matches(&conn, "quartz"), 0, "the old body is gone");
|
|
||||||
|
|
||||||
// And the idempotent entry point still repairs a row that really does hold
|
|
||||||
// content — writing over the top of a DONE row with no update first, which
|
|
||||||
// is what the content pass and the watcher do.
|
|
||||||
let tx = conn.transaction().unwrap();
|
|
||||||
set_content_done(&tx, id, "sphinx jasper", zstd_of("sphinx jasper").as_deref()).unwrap();
|
|
||||||
tx.commit().unwrap();
|
|
||||||
|
|
||||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM searchabletext"), 1);
|
|
||||||
assert_eq!(count(&conn, "SELECT COUNT(*) FROM documents_text"), 1);
|
|
||||||
assert_eq!(matches(&conn, "jasper"), 1);
|
|
||||||
assert_eq!(matches(&conn, "onyx"), 0);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The size report reads each body's uncompressed length out of its zstd
|
/// The size report reads each body's uncompressed length out of its zstd
|
||||||
/// frame header instead of from a stored column, which works only because
|
/// frame header instead of from a stored column, which works only because
|
||||||
/// [`DocEncoder`] compresses through `ZSTD_compress2` — the API that is
|
/// [`DocEncoder`] compresses through `ZSTD_compress2` — the API that is
|
||||||
|
|
@ -118,7 +47,8 @@ fn insert_update_delete_round_trip() {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "a.txt",
|
name: "a.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/a.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 42,
|
size: 42,
|
||||||
mtime: 1_700_000_000,
|
mtime: 1_700_000_000,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -171,7 +101,8 @@ fn insert_writes_content_state_from_needs_content() {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
let mut row = NewFile {
|
let mut row = NewFile {
|
||||||
name: "claimed.txt",
|
name: "claimed.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/claimed.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -181,6 +112,7 @@ fn insert_writes_content_state_from_needs_content() {
|
||||||
};
|
};
|
||||||
let claimed = insert_file(&tx, &row).unwrap().expect("unique path");
|
let claimed = insert_file(&tx, &row).unwrap().expect("unique path");
|
||||||
row.name = "unclaimed.mp4";
|
row.name = "unclaimed.mp4";
|
||||||
|
row.path = "/tmp/unclaimed.mp4";
|
||||||
row.mime = Some("video/mp4");
|
row.mime = Some("video/mp4");
|
||||||
row.needs_content = false;
|
row.needs_content = false;
|
||||||
let unclaimed = insert_file(&tx, &row).unwrap().expect("unique path");
|
let unclaimed = insert_file(&tx, &row).unwrap().expect("unique path");
|
||||||
|
|
@ -207,7 +139,8 @@ fn update_writes_content_state_from_needs_content() {
|
||||||
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
let mut row = NewFile {
|
let mut row = NewFile {
|
||||||
name: "a.txt",
|
name: "a.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/a.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 10,
|
size: 10,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -283,7 +216,8 @@ fn insert_file_twice_on_same_path_is_idempotent() {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
let row = NewFile {
|
let row = NewFile {
|
||||||
name: "dup.txt",
|
name: "dup.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/dup.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -300,8 +234,8 @@ fn insert_file_twice_on_same_path_is_idempotent() {
|
||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
let (id_read,): (i64,) = tx
|
let (id_read,): (i64,) = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id FROM files WHERE parent = ?1 AND name = ?2",
|
"SELECT id FROM files WHERE path = ?1",
|
||||||
params!["/tmp/", "dup.txt"],
|
params!["/tmp/dup.txt"],
|
||||||
|r| Ok((r.get(0)?,)),
|
|r| Ok((r.get(0)?,)),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -316,11 +250,13 @@ fn delete_subtree_clears_every_dependent_table() {
|
||||||
let p = tmp_path();
|
let p = tmp_path();
|
||||||
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
let add = |tx: &Transaction<'_>, path: &str| -> i64 {
|
let add = |tx: &Transaction<'_>, path: &str| -> i64 {
|
||||||
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
|
let name = path.rsplit('/').next().unwrap();
|
||||||
|
let parent = &path[..path.rfind('/').unwrap()];
|
||||||
let id = insert_file(
|
let id = insert_file(
|
||||||
tx,
|
tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name,
|
name,
|
||||||
|
path,
|
||||||
parent,
|
parent,
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
|
|
@ -370,7 +306,7 @@ fn delete_subtree_clears_every_dependent_table() {
|
||||||
|
|
||||||
let survivors: Vec<String> = {
|
let survivors: Vec<String> = {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT parent || name FROM files ORDER BY parent, name")
|
.prepare("SELECT path FROM files ORDER BY path")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let v = stmt
|
let v = stmt
|
||||||
.query_map([], |r| r.get::<_, String>(0))
|
.query_map([], |r| r.get::<_, String>(0))
|
||||||
|
|
@ -391,12 +327,13 @@ fn seeded(conn: &mut Connection, paths: &[&str]) -> std::collections::HashMap<St
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
let mut ids = std::collections::HashMap::new();
|
let mut ids = std::collections::HashMap::new();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
// The indexer's own split, so the separator stays with the parent.
|
let name = path.rsplit('/').next().unwrap();
|
||||||
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
|
let parent = &path[..path.rfind('/').unwrap()];
|
||||||
let id = insert_file(
|
let id = insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name,
|
name,
|
||||||
|
path,
|
||||||
parent,
|
parent,
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
|
|
@ -450,7 +387,7 @@ fn delete_outside_ranges_keeps_exactly_the_configured_roots() {
|
||||||
|
|
||||||
let survivors: Vec<String> = {
|
let survivors: Vec<String> = {
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT parent || name FROM files ORDER BY parent, name")
|
.prepare("SELECT path FROM files ORDER BY path")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let v = stmt
|
let v = stmt
|
||||||
.query_map([], |r| r.get::<_, String>(0))
|
.query_map([], |r| r.get::<_, String>(0))
|
||||||
|
|
@ -670,47 +607,40 @@ fn rows_in_range_page_walks_the_range_once() {
|
||||||
|
|
||||||
let range = crate::file_handling::ExtractCursor::for_root("/t");
|
let range = crate::file_handling::ExtractCursor::for_root("/t");
|
||||||
let mut seen = Vec::new();
|
let mut seen = Vec::new();
|
||||||
// `(lo, "")`: no stored name is empty, so this sorts just below the
|
let mut after = range.lo.clone();
|
||||||
// range's first row.
|
|
||||||
let mut after = (range.lo.clone(), String::new());
|
|
||||||
loop {
|
loop {
|
||||||
let page = rows_in_range_page(&conn, &after.0, &after.1, &range.hi, 2).unwrap();
|
let page = rows_in_range_page(&conn, &after, &range.hi, 2).unwrap();
|
||||||
let Some(last) = page.last() else { break };
|
let Some(last) = page.last() else { break };
|
||||||
after = (last.parent.clone(), last.name.clone());
|
after = last.path.clone();
|
||||||
seen.extend(page.into_iter().map(|r| r.path));
|
seen.extend(page.into_iter().map(|r| r.path));
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
seen,
|
seen,
|
||||||
vec!["/t/a.txt", "/t/deep/b.txt", "/t/deep/deeper/c.txt"],
|
vec!["/t/a.txt", "/t/deep/b.txt", "/t/deep/deeper/c.txt"],
|
||||||
"in (parent, name) order, once each, and the prefix siblings are outside"
|
"in path order, once each, and the prefix siblings are outside"
|
||||||
);
|
);
|
||||||
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `dir_rows` must *seek* on `idx_files_parent`, never scan the table.
|
/// `idx_files_parent` carries `name` and `mtime` so `dir_rows` never touches
|
||||||
///
|
/// the table heap; trimming it back to `(parent)` would silently reintroduce
|
||||||
/// It is deliberately not index-only any more: the index stopped carrying
|
/// a row fetch per entry.
|
||||||
/// `mtime` when `UNIQUE(parent, name)` took over as the row key, so the plan
|
|
||||||
/// is a seek plus a row fetch per entry. See the index's own comment in
|
|
||||||
/// `schema.rs` for why that trade was taken — and note the thing this test
|
|
||||||
/// guards is the seek, which is what keeps the cost per *directory* rather
|
|
||||||
/// than per *tree*.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dir_rows_seeks_the_parent_index() {
|
fn dir_rows_is_served_entirely_from_the_index() {
|
||||||
let p = tmp_path();
|
let p = tmp_path();
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
let plan: String = conn
|
let plan: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"EXPLAIN QUERY PLAN SELECT name, mtime FROM files WHERE parent = ?1",
|
"EXPLAIN QUERY PLAN SELECT name, mtime FROM files WHERE parent = ?1",
|
||||||
params!["/some/dir/"],
|
params!["/some/dir"],
|
||||||
|r| r.get(3),
|
|r| r.get(3),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
plan.contains("SEARCH") && plan.contains("idx_files_parent"),
|
plan.contains("COVERING INDEX idx_files_parent"),
|
||||||
"dir_rows must seek the parent index, got: {}",
|
"dir_rows must be index-only, got: {}",
|
||||||
plan
|
plan
|
||||||
);
|
);
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
@ -725,7 +655,7 @@ fn the_subtree_range_is_an_index_seek_not_a_scan() {
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
let plan: String = conn
|
let plan: String = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"EXPLAIN QUERY PLAN DELETE FROM files WHERE parent >= ?1 AND parent < ?2",
|
"EXPLAIN QUERY PLAN DELETE FROM files WHERE path >= ?1 AND path < ?2",
|
||||||
params!["/tree/", "/tree0"],
|
params!["/tree/", "/tree0"],
|
||||||
|r| r.get(3),
|
|r| r.get(3),
|
||||||
)
|
)
|
||||||
|
|
@ -739,43 +669,6 @@ fn the_subtree_range_is_an_index_seek_not_a_scan() {
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The keyset page must be one index walk: the row-value comparison against
|
|
||||||
/// `(parent, name)` is what lets SQLite seek straight to the cursor and read
|
|
||||||
/// forward, with no temp b-tree to satisfy the `ORDER BY`.
|
|
||||||
#[test]
|
|
||||||
fn the_reconcile_page_seeks_and_does_not_sort() {
|
|
||||||
let p = tmp_path();
|
|
||||||
let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
let mut stmt = conn
|
|
||||||
.prepare(
|
|
||||||
"EXPLAIN QUERY PLAN
|
|
||||||
SELECT id, parent, name, size, mime, content_state FROM files
|
|
||||||
WHERE (parent, name) > (?1, ?2) AND parent < ?3
|
|
||||||
ORDER BY parent, name
|
|
||||||
LIMIT ?4",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let plan: Vec<String> = stmt
|
|
||||||
.query_map(params!["/tree/", "", "/tree0", 2], |r| r.get(3))
|
|
||||||
.unwrap()
|
|
||||||
.map(|r| r.unwrap())
|
|
||||||
.collect();
|
|
||||||
let plan = plan.join(" | ");
|
|
||||||
assert!(
|
|
||||||
plan.contains("SEARCH") && plan.contains("idx_files_parent"),
|
|
||||||
"keyset page must seek the parent index, got: {}",
|
|
||||||
plan
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!plan.contains("TEMP B-TREE"),
|
|
||||||
"the index order must satisfy the ORDER BY outright, got: {}",
|
|
||||||
plan
|
|
||||||
);
|
|
||||||
drop(stmt);
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn last_full_index_round_trip() {
|
fn last_full_index_round_trip() {
|
||||||
let p = tmp_path();
|
let p = tmp_path();
|
||||||
|
|
@ -800,7 +693,8 @@ fn checkpoint_and_close_truncates_wal() {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "w.txt",
|
name: "w.txt",
|
||||||
parent: "/tmp/",
|
path: "/tmp/w.txt",
|
||||||
|
parent: "/tmp",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -831,12 +725,14 @@ fn checkpoint_and_close_truncates_wal() {
|
||||||
fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
|
fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
for i in range {
|
for i in range {
|
||||||
|
let path = format!("/tmp/bulk/{}.txt", i);
|
||||||
let name = format!("{}.txt", i);
|
let name = format!("{}.txt", i);
|
||||||
let id = insert_file(
|
let id = insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: &name,
|
name: &name,
|
||||||
parent: "/tmp/bulk/",
|
path: &path,
|
||||||
|
parent: "/tmp/bulk",
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -919,12 +815,14 @@ fn a_busy_reader_defeats_the_autocheckpoint_but_not_a_forced_one() {
|
||||||
fn seed_bare(conn: &mut Connection, range: std::ops::Range<usize>) {
|
fn seed_bare(conn: &mut Connection, range: std::ops::Range<usize>) {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
for i in range {
|
for i in range {
|
||||||
|
let path = format!("/tmp/bare/{}.txt", i);
|
||||||
let name = format!("{}.txt", i);
|
let name = format!("{}.txt", i);
|
||||||
insert_file(
|
insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: &name,
|
name: &name,
|
||||||
parent: "/tmp/bare/",
|
path: &path,
|
||||||
|
parent: "/tmp/bare",
|
||||||
size: i as u64,
|
size: i as u64,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -1081,7 +979,8 @@ fn set_content_failed_writes_failed_table() {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: "oops.bin",
|
name: "oops.bin",
|
||||||
parent: "/tmp/",
|
path: "/tmp/oops.bin",
|
||||||
|
parent: "/tmp",
|
||||||
size: 0,
|
size: 0,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -1120,11 +1019,13 @@ fn set_content_failed_writes_failed_table() {
|
||||||
|
|
||||||
/// Insert one row under `path`, born pending when `needs_content`.
|
/// Insert one row under `path`, born pending when `needs_content`.
|
||||||
fn insert_at(tx: &Transaction<'_>, path: &str, needs_content: bool) -> i64 {
|
fn insert_at(tx: &Transaction<'_>, path: &str, needs_content: bool) -> i64 {
|
||||||
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
|
let name = path.rsplit('/').next().unwrap();
|
||||||
|
let parent = &path[..path.rfind('/').unwrap()];
|
||||||
insert_file(
|
insert_file(
|
||||||
tx,
|
tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name,
|
name,
|
||||||
|
path,
|
||||||
parent,
|
parent,
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
|
|
@ -1275,40 +1176,3 @@ fn prune_root_stats_drops_every_figure_of_a_dropped_root() {
|
||||||
drop(conn);
|
drop(conn);
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The vanished-directory sweep walks `for_each_parent_in_range`, and its
|
|
||||||
/// range has to include the root's *own* directory or the files sitting
|
|
||||||
/// directly in a root are never reconciled.
|
|
||||||
///
|
|
||||||
/// It does now only because every stored parent ends in a separator: the root's
|
|
||||||
/// parent is spelled `/tree/`, which is the range's `lo` exactly. With the bare
|
|
||||||
/// `/tree` an older schema stored, it sorted *below* `lo` and the sweep skipped
|
|
||||||
/// it — harmless then only because a readable root is always in `seen_dirs`.
|
|
||||||
#[test]
|
|
||||||
fn the_parent_scan_reaches_the_roots_own_directory() {
|
|
||||||
let p = tmp_path();
|
|
||||||
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
seeded(
|
|
||||||
&mut conn,
|
|
||||||
&[
|
|
||||||
"/tree/top.txt", // directly in the root
|
|
||||||
"/tree/deep/b.txt", // a subdirectory
|
|
||||||
"/tree/deep/deeper/c.txt", // deeper still
|
|
||||||
"/tree2/outside.txt", // prefix sibling: outside
|
|
||||||
"/treeX/outside.txt", // and the LIKE-metacharacter neighbour
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
let range = crate::file_handling::ExtractCursor::for_root("/tree");
|
|
||||||
let mut seen = Vec::new();
|
|
||||||
for_each_parent_in_range(&conn, &range.lo, &range.hi, |parent| seen.push(parent)).unwrap();
|
|
||||||
seen.sort();
|
|
||||||
assert_eq!(
|
|
||||||
seen,
|
|
||||||
vec!["/tree/", "/tree/deep/", "/tree/deep/deeper/"],
|
|
||||||
"the root's own directory is in range, and the siblings are not"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,7 @@ pub const PRAGMAS_READONLY: &str = "
|
||||||
///
|
///
|
||||||
/// Two of these can exist per indexing root, so the cache size is multiplied
|
/// Two of these can exist per indexing root, so the cache size is multiplied
|
||||||
/// by the root count. 1 MiB is sized for the walk's queries, which each read
|
/// by the root count. 1 MiB is sized for the walk's queries, which each read
|
||||||
/// one range of `idx_files_parent` once and never revisit it — plus, since
|
/// one range of `idx_files_parent` once and never revisit it. The feeder's
|
||||||
/// that index stopped carrying `mtime`, one table-row fetch per entry in the
|
|
||||||
/// range. Those land on a handful of pages while a directory's rows stay
|
|
||||||
/// rowid-adjacent; **this is the number to raise** if a tree churned across
|
|
||||||
/// many incremental runs ever scatters them far enough to matter. The feeder's
|
|
||||||
/// paging is the same shape, but its one-off `count_extract_scope` at pass
|
/// paging is the same shape, but its one-off `count_extract_scope` at pass
|
||||||
/// start is not: that scans the root's whole path range fetching a row per
|
/// start is not: that scans the root's whole path range fetching a row per
|
||||||
/// entry, so on a large root it is a cold read all the way through. It is
|
/// entry, so on a large root it is a cold read all the way through. It is
|
||||||
|
|
@ -160,20 +156,10 @@ CREATE TABLE schema_info (
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- **There is no `path` column.** A file's path is `parent || name`, and
|
|
||||||
-- storing it a third time cost ~43% of the per-row footprint: over a
|
|
||||||
-- 400k-file corpus (avg path 77 bytes, parent 59, name 17) ~394 bytes/row
|
|
||||||
-- against the ~226 below, or ~170 MB per million files. It also widened the
|
|
||||||
-- row by a third, and three of the search cascade's passes scan every row —
|
|
||||||
-- on an encrypted index each extra page is an AES-CBC decrypt and an
|
|
||||||
-- HMAC-SHA512 verify. Reassembling a path is a `push_str` in the one place
|
|
||||||
-- that needs one (`file_handling::split_db_path` is the inverse).
|
|
||||||
--
|
|
||||||
-- `parent` always ends in the platform separator; see `dir_to_db_parent` for
|
|
||||||
-- why the whole design turns on that.
|
|
||||||
CREATE TABLE files (
|
CREATE TABLE files (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL UNIQUE,
|
||||||
parent TEXT NOT NULL,
|
parent TEXT NOT NULL,
|
||||||
size INTEGER NOT NULL,
|
size INTEGER NOT NULL,
|
||||||
mtime INTEGER NOT NULL,
|
mtime INTEGER NOT NULL,
|
||||||
|
|
@ -183,32 +169,17 @@ CREATE TABLE files (
|
||||||
hash BLOB
|
hash BLOB
|
||||||
);
|
);
|
||||||
|
|
||||||
-- The identity of a row, and the only index `parent` needs.
|
-- Covering, not just `(parent)`. The walk's row prefetcher issues
|
||||||
--
|
-- `SELECT name, mtime FROM files WHERE parent = ?` once per directory — the
|
||||||
-- It replaces both of what came before — a `UNIQUE(path)` and a covering
|
-- hottest read in a full run — and with the bare index that is an index probe
|
||||||
-- `(parent, name, mtime)` — and dropping the second is the deliberate half.
|
-- plus a table-row fetch per entry. Those fetches are cold by design: the walk
|
||||||
-- The walk's row prefetcher issues `SELECT name, mtime FROM files WHERE
|
-- reader deliberately runs on a 1 MiB page cache (see `PRAGMAS_WALK_READER`).
|
||||||
-- parent = ?` once per directory, the hottest read in a full run, and without
|
-- Carrying `name` and `mtime` in the index makes it an index-only scan.
|
||||||
-- `mtime` in the index that is a table-row fetch per entry. It is affordable
|
-- `parent` stays leading, so `SELECT DISTINCT parent` range scans and
|
||||||
-- because the prefetcher is one thread ahead of four walk workers that each
|
-- `paths_in_dir` are unaffected.
|
||||||
-- spend a `stat` *and* a SHA-256 of the path per file (`crate::walk`), so it
|
CREATE INDEX idx_files_parent ON files(parent, name, mtime);
|
||||||
-- has budget to spend; because a directory's rows are written in one batch and
|
|
||||||
-- so are rowid-adjacent; and because the row is now narrow enough that a
|
|
||||||
-- 1 MiB cache holds ~7,200 of them. `mtime` cannot simply be appended here —
|
|
||||||
-- `UNIQUE(parent, name, mtime)` would let the same file be inserted twice
|
|
||||||
-- under two mtimes.
|
|
||||||
--
|
|
||||||
-- If the prefetcher is ever measured falling behind, raise
|
|
||||||
-- `PRAGMAS_WALK_READER` — paid for out of the space this index no longer
|
|
||||||
-- occupies — rather than restoring the covering one.
|
|
||||||
CREATE UNIQUE INDEX idx_files_parent ON files(parent, name);
|
|
||||||
CREATE INDEX idx_files_mtime ON files(mtime);
|
CREATE INDEX idx_files_mtime ON files(mtime);
|
||||||
-- No index on `type`. The only query that touches it is the kind filter's
|
CREATE INDEX idx_files_type ON files(type);
|
||||||
-- `(f.type & ?) != 0` (`crate::query::translator`), and a bitmask test on the
|
|
||||||
-- left of the operator is not sargable, so SQLite scans regardless — nothing
|
|
||||||
-- can seek such an index and nothing orders or groups by the column. Carrying
|
|
||||||
-- one measured ~10% on inserts and 805 pages at 300k rows, and those pages
|
|
||||||
-- compete with the ones search wants resident.
|
|
||||||
CREATE INDEX idx_files_mime ON files(mime);
|
CREATE INDEX idx_files_mime ON files(mime);
|
||||||
CREATE INDEX idx_files_hash ON files(hash);
|
CREATE INDEX idx_files_hash ON files(hash);
|
||||||
CREATE INDEX idx_files_content_pending ON files(id) WHERE content_state = 0;
|
CREATE INDEX idx_files_content_pending ON files(id) WHERE content_state = 0;
|
||||||
|
|
|
||||||
|
|
@ -86,104 +86,34 @@ const ODF_SHEET: TextSpec = TextSpec {
|
||||||
separator: Some(' '),
|
separator: Some(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The text an `&entity;` or `Ӓ` reference stands for.
|
|
||||||
///
|
|
||||||
/// quick-xml 0.41 reports a reference as its own event instead of resolving
|
|
||||||
/// it inside the surrounding `Text`, so a reader that ignores this event
|
|
||||||
/// silently drops every `&`, `<` and `’` from the document —
|
|
||||||
/// no error, just missing characters in the index. Only the five predefined
|
|
||||||
/// entities and numeric references are resolvable without a DTD; anything
|
|
||||||
/// else is a document-defined entity we cannot expand, and is skipped.
|
|
||||||
fn entity_text(raw: &str) -> Option<String> {
|
|
||||||
if let Some(digits) = raw.strip_prefix('#') {
|
|
||||||
let code = match digits.strip_prefix(['x', 'X']) {
|
|
||||||
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
|
|
||||||
None => digits.parse::<u32>().ok()?,
|
|
||||||
};
|
|
||||||
let c = char::from_u32(code)?;
|
|
||||||
// `char::from_u32` accepts far more than XML's character production
|
|
||||||
// does: every C0 control but tab, newline and carriage return is
|
|
||||||
// forbidden, and `�` in particular would put a literal NUL into the
|
|
||||||
// indexed text and from there into an FTS5 column. `None` here reaches
|
|
||||||
// the callers as the same "unknown entity" error an unexpandable name
|
|
||||||
// gets — a `failed_files` row naming the file, which is the visible
|
|
||||||
// outcome this extractor prefers to a quietly mangled document.
|
|
||||||
let legal = !c.is_control() || matches!(c, '\t' | '\n' | '\r');
|
|
||||||
return legal.then(|| String::from(c));
|
|
||||||
}
|
|
||||||
quick_xml::escape::resolve_predefined_entity(raw).map(String::from)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Append the text `spec` selects out of `xml` to `out`.
|
/// Append the text `spec` selects out of `xml` to `out`.
|
||||||
///
|
///
|
||||||
/// Text-bearing elements are counted, not flagged. ODF nests them — a
|
/// `in_text` is a flag rather than a depth count, which means a closing
|
||||||
/// `<text:span>` inside a `<text:p>` — and a flag made the span's own close
|
/// `</text:span>` ends the run even though its enclosing `<text:p>` is still
|
||||||
/// end the run, dropping every character between it and the paragraph's
|
/// open.
|
||||||
/// close. Counting also gives the separator somewhere honest to go: it
|
|
||||||
/// belongs after a *run*, and since quick-xml 0.41 a run arrives as several
|
|
||||||
/// events, so emitting one per event put a space in the middle of every cell
|
|
||||||
/// containing an entity.
|
|
||||||
fn collect_xml_text(xml: &str, spec: &TextSpec, out: &mut String) -> Result<(), Box<dyn Error>> {
|
fn collect_xml_text(xml: &str, spec: &TextSpec, out: &mut String) -> Result<(), Box<dyn Error>> {
|
||||||
let mut reader = Reader::from_str(xml);
|
let mut reader = Reader::from_str(xml);
|
||||||
// Deliberately no `trim_text`: it trims each *event*, and since 0.41 an
|
reader.trim_text(true);
|
||||||
// entity reference splits the character data around it into separate
|
|
||||||
// events — so `Jack & Jill` would come back as `Jack&Jill`, with the
|
|
||||||
// spaces trimmed off the ends of the two fragments. Nothing needs it
|
|
||||||
// either: whitespace between elements arrives while the `in_text`/`in_cell`
|
|
||||||
// flag is false and is ignored there, and whitespace *inside* a
|
|
||||||
// text-bearing element is content.
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
// How many text-bearing elements are open. The run ends when it returns
|
let mut in_text = false;
|
||||||
// to zero, not when the innermost one closes.
|
|
||||||
let mut depth = 0usize;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
Ok(Event::Start(ref e)) => {
|
Ok(Event::Start(ref e)) => {
|
||||||
if spec.text.contains(&e.name().as_ref()) {
|
if spec.text.contains(&e.name().as_ref()) {
|
||||||
depth += 1;
|
in_text = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Event::Text(e)) if depth > 0 => {
|
Ok(Event::Text(e)) if in_text => {
|
||||||
out.push_str(&e.decode()?);
|
out.push_str(&e.unescape()?);
|
||||||
}
|
if let Some(sep) = spec.separator {
|
||||||
// An entity reference is its own event in 0.41; without this arm
|
out.push(sep);
|
||||||
// every `&` in a document would vanish from the index.
|
}
|
||||||
Ok(Event::GeneralRef(e)) if depth > 0 => {
|
|
||||||
let raw = e.decode()?;
|
|
||||||
// An entity nothing can expand is an error, as it was when
|
|
||||||
// `unescape` resolved these inline: dropping it would take
|
|
||||||
// characters out of the indexed text with nothing to show for
|
|
||||||
// it, and this reader has no DTD to define one with.
|
|
||||||
let text = entity_text(&raw)
|
|
||||||
.ok_or_else(|| format!("Error parsing XML: unknown entity &{};", raw))?;
|
|
||||||
out.push_str(&text);
|
|
||||||
}
|
}
|
||||||
Ok(Event::End(ref e)) => {
|
Ok(Event::End(ref e)) => {
|
||||||
let name = e.name();
|
let name = e.name();
|
||||||
if spec.text.contains(&name.as_ref()) {
|
if spec.text.contains(&name.as_ref()) {
|
||||||
depth = depth.saturating_sub(1);
|
in_text = false;
|
||||||
// Closing the outermost one closes the run.
|
|
||||||
if depth == 0 {
|
|
||||||
if let Some(sep) = spec.separator {
|
|
||||||
out.push(sep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if spec.breaks.contains(&name.as_ref()) {
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A self-closed element gets no `Start` and no `End` of its own,
|
|
||||||
// so `<text:p/>` — ODF's blank line — would otherwise lose both
|
|
||||||
// its separator and its paragraph break. It carries no text, so
|
|
||||||
// the run it opens is empty and closes immediately.
|
|
||||||
Ok(Event::Empty(ref e)) => {
|
|
||||||
let name = e.name();
|
|
||||||
if depth == 0 && spec.text.contains(&name.as_ref()) {
|
|
||||||
if let Some(sep) = spec.separator {
|
|
||||||
out.push(sep);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if spec.breaks.contains(&name.as_ref()) {
|
if spec.breaks.contains(&name.as_ref()) {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
|
@ -305,56 +235,18 @@ fn shared_strings<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Vec<String> {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
};
|
};
|
||||||
let mut reader = Reader::from_str(&xml);
|
let mut reader = Reader::from_str(&xml);
|
||||||
// Deliberately no `trim_text`: it trims each *event*, and since 0.41 an
|
reader.trim_text(true);
|
||||||
// entity reference splits the character data around it into separate
|
|
||||||
// events — so `Jack & Jill` would come back as `Jack&Jill`, with the
|
|
||||||
// spaces trimmed off the ends of the two fragments. Nothing needs it
|
|
||||||
// either: whitespace between elements arrives while the `in_text`/`in_cell`
|
|
||||||
// flag is false and is ignored there, and whitespace *inside* a
|
|
||||||
// text-bearing element is content.
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut strings = Vec::new();
|
let mut strings = Vec::new();
|
||||||
let mut in_text = false;
|
let mut in_text = false;
|
||||||
// One `<t>` is one shared string, but it is not one event: an entity
|
|
||||||
// reference inside it arrives separately and splits the character data
|
|
||||||
// around it. Accumulated here and pushed on the closing tag, or a cell
|
|
||||||
// containing `&` would become three table entries and every later
|
|
||||||
// index would point at the wrong one.
|
|
||||||
let mut current = String::new();
|
|
||||||
loop {
|
loop {
|
||||||
match reader.read_event_into(&mut buf) {
|
match reader.read_event_into(&mut buf) {
|
||||||
Ok(Event::Start(ref e)) if e.name().as_ref() == b"t" => {
|
Ok(Event::Start(ref e)) if e.name().as_ref() == b"t" => in_text = true,
|
||||||
in_text = true;
|
Ok(Event::Text(e)) if in_text => match e.unescape() {
|
||||||
current.clear();
|
Ok(s) => strings.push(s.into_owned()),
|
||||||
}
|
|
||||||
// `<t/>` — an empty cell. quick-xml reports a self-closed element
|
|
||||||
// as its own event with no `Start` and no `End`, so without this
|
|
||||||
// arm the entry is never pushed and **every later index is off by
|
|
||||||
// one**: `collect_sheet` then renders a real string for the wrong
|
|
||||||
// cell, with nothing anywhere reporting a problem. LibreOffice,
|
|
||||||
// openpyxl and POI all write `<si><t/></si>` for a blank, so this
|
|
||||||
// is ordinary output rather than a crafted file.
|
|
||||||
Ok(Event::Empty(ref e)) if e.name().as_ref() == b"t" => {
|
|
||||||
strings.push(String::new());
|
|
||||||
}
|
|
||||||
Ok(Event::Text(e)) if in_text => match e.decode() {
|
|
||||||
Ok(s) => current.push_str(&s),
|
|
||||||
Err(_) => return strings,
|
Err(_) => return strings,
|
||||||
},
|
},
|
||||||
Ok(Event::GeneralRef(e)) if in_text => {
|
Ok(Event::End(ref e)) if e.name().as_ref() == b"t" => in_text = false,
|
||||||
// Unlike the other two readers this one cannot fail — a
|
|
||||||
// missing table is not an error here — so an entity nothing
|
|
||||||
// can expand is simply left out.
|
|
||||||
if let Ok(raw) = e.decode() {
|
|
||||||
if let Some(text) = entity_text(&raw) {
|
|
||||||
current.push_str(&text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(Event::End(ref e)) if e.name().as_ref() == b"t" => {
|
|
||||||
in_text = false;
|
|
||||||
strings.push(std::mem::take(&mut current));
|
|
||||||
}
|
|
||||||
Ok(Event::Eof) | Err(_) => break,
|
Ok(Event::Eof) | Err(_) => break,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
@ -367,13 +259,7 @@ fn shared_strings<R: Read + Seek>(archive: &mut ZipArchive<R>) -> Vec<String> {
|
||||||
/// rather than text of its own; every other type holds its value inline.
|
/// rather than text of its own; every other type holds its value inline.
|
||||||
fn collect_sheet(xml: &str, strings: &[String], out: &mut String) -> Result<(), Box<dyn Error>> {
|
fn collect_sheet(xml: &str, strings: &[String], out: &mut String) -> Result<(), Box<dyn Error>> {
|
||||||
let mut reader = Reader::from_str(xml);
|
let mut reader = Reader::from_str(xml);
|
||||||
// Deliberately no `trim_text`: it trims each *event*, and since 0.41 an
|
reader.trim_text(true);
|
||||||
// entity reference splits the character data around it into separate
|
|
||||||
// events — so `Jack & Jill` would come back as `Jack&Jill`, with the
|
|
||||||
// spaces trimmed off the ends of the two fragments. Nothing needs it
|
|
||||||
// either: whitespace between elements arrives while the `in_text`/`in_cell`
|
|
||||||
// flag is false and is ignored there, and whitespace *inside* a
|
|
||||||
// text-bearing element is content.
|
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
let mut in_cell = false;
|
let mut in_cell = false;
|
||||||
let mut cell_type = String::new();
|
let mut cell_type = String::new();
|
||||||
|
|
@ -401,49 +287,19 @@ fn collect_sheet(xml: &str, strings: &[String], out: &mut String) -> Result<(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Event::Text(e)) if in_cell => {
|
Ok(Event::Text(e)) if in_cell => {
|
||||||
let text = e.decode()?;
|
let text = e.unescape()?;
|
||||||
if cell_type == "s" {
|
if cell_type == "s" {
|
||||||
// A shared-string reference. An index past the end of the
|
// A shared-string reference. An index past the end of the
|
||||||
// table is a corrupt workbook, not something to guess at.
|
// table is a corrupt workbook, not something to guess at.
|
||||||
//
|
if let Some(s) = text.parse::<usize>().ok().and_then(|i| strings.get(i)) {
|
||||||
// `trim` because this reader no longer sets `trim_text`
|
|
||||||
// (see the comment above): a generator that indents its
|
|
||||||
// XML hands `<v>` over as "\n 0\n", and an untrimmed
|
|
||||||
// parse would fail and drop the string with nothing to
|
|
||||||
// show for it. Whitespace around an integer index is not
|
|
||||||
// content, unlike whitespace inside a `<t>`.
|
|
||||||
if let Some(s) = text
|
|
||||||
.trim()
|
|
||||||
.parse::<usize>()
|
|
||||||
.ok()
|
|
||||||
.and_then(|i| strings.get(i))
|
|
||||||
{
|
|
||||||
out.push_str(s);
|
out.push_str(s);
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
}
|
}
|
||||||
} else if !text.trim().is_empty() {
|
} else {
|
||||||
// Whitespace-only fragments are the indentation *between*
|
|
||||||
// a cell's child elements, which reaches this arm now that
|
|
||||||
// the reader no longer sets `trim_text`. Skipped rather
|
|
||||||
// than pushed: `in_cell` is a flag, so it cannot tell an
|
|
||||||
// indent from a value, and a cell whose entire content is
|
|
||||||
// whitespace contributes nothing to a search index either
|
|
||||||
// way. The value itself is pushed whole — no `trim` — so a
|
|
||||||
// deliberate `xml:space="preserve"` inline string keeps
|
|
||||||
// its shape.
|
|
||||||
out.push_str(&text);
|
out.push_str(&text);
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// See `entity_text`. Only inline values can carry one: a `t="s"`
|
|
||||||
// cell's text is an integer index, and an entity inside it would
|
|
||||||
// be a corrupt workbook rather than a character to recover.
|
|
||||||
Ok(Event::GeneralRef(e)) if in_cell && cell_type != "s" => {
|
|
||||||
let raw = e.decode()?;
|
|
||||||
let text = entity_text(&raw)
|
|
||||||
.ok_or_else(|| format!("Error parsing XML: unknown entity &{};", raw))?;
|
|
||||||
out.push_str(&text);
|
|
||||||
}
|
|
||||||
Ok(Event::End(ref e)) => {
|
Ok(Event::End(ref e)) => {
|
||||||
let name = e.name();
|
let name = e.name();
|
||||||
if name.as_ref() == b"c" {
|
if name.as_ref() == b"c" {
|
||||||
|
|
@ -520,111 +376,6 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
/// Entity references must survive extraction.
|
|
||||||
///
|
|
||||||
/// quick-xml 0.41 reports `&` as its own `GeneralRef` event instead of
|
|
||||||
/// resolving it into the surrounding text, so a reader that only handles
|
|
||||||
/// `Event::Text` loses the character with no error to show for it. This is
|
|
||||||
/// the test that makes that visible: it fails by producing "Blake Co"
|
|
||||||
/// rather than by failing to compile.
|
|
||||||
#[test]
|
|
||||||
fn entity_references_survive_extraction() {
|
|
||||||
let body = "<w:document><w:body><w:p><w:r>\
|
|
||||||
<w:t>Blake & Co <tags> ’24 ’25</w:t>\
|
|
||||||
</w:r></w:p></w:body></w:document>";
|
|
||||||
let path = container("docx-entities", "docx", &[("word/document.xml", body)]);
|
|
||||||
let out = OfficeExtractor.extract(&path).expect("extract");
|
|
||||||
assert!(
|
|
||||||
out.text.contains("Blake & Co"),
|
|
||||||
"predefined entity lost: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
out.text.contains("<tags>"),
|
|
||||||
"angle-bracket entities lost: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
out.text.contains('\u{2019}'),
|
|
||||||
"numeric entities lost: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!out.text.contains("&") && !out.text.contains("&#"),
|
|
||||||
"entities left unresolved: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same, through the shared-string table an `.xlsx` cell indexes into
|
|
||||||
/// — a separate reader, and so a separate chance to drop the character.
|
|
||||||
#[test]
|
|
||||||
fn entity_references_survive_shared_strings() {
|
|
||||||
let shared = "<sst><si><t>Jack & Jill</t></si></sst>";
|
|
||||||
let sheet = "<worksheet><sheetData><row>\
|
|
||||||
<c t=\"s\"><v>0</v></c></row></sheetData></worksheet>";
|
|
||||||
let path = container(
|
|
||||||
"xlsx-entities",
|
|
||||||
"xlsx",
|
|
||||||
&[
|
|
||||||
("xl/sharedStrings.xml", shared),
|
|
||||||
("xl/worksheets/sheet1.xml", sheet),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let out = OfficeExtractor.extract(&path).expect("extract");
|
|
||||||
assert!(
|
|
||||||
out.text.contains("Jack & Jill"),
|
|
||||||
"entity lost through the shared-string table: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A shared-string reference must survive an indented `<v>`.
|
|
||||||
///
|
|
||||||
/// The reader deliberately does not set `trim_text` (an entity reference
|
|
||||||
/// splits the character data around it, and trimming each fragment would
|
|
||||||
/// eat the spaces at the split). A `t="s"` cell's `<v>` is an integer
|
|
||||||
/// index, though, so a generator that pretty-prints its sheet XML hands
|
|
||||||
/// this reader `"\n 0\n "` — and an untrimmed `parse::<usize>()`
|
|
||||||
/// fails, dropping the cell's text with no error and no `failed_files`
|
|
||||||
/// row. Whitespace-only fragments between a cell's children must not
|
|
||||||
/// reach the output either.
|
|
||||||
#[test]
|
|
||||||
fn an_indented_shared_string_reference_still_resolves() {
|
|
||||||
let shared = "<sst><si><t>Marmalade</t></si></sst>";
|
|
||||||
let sheet = "<worksheet>\n <sheetData>\n <row>\n \
|
|
||||||
<c t=\"s\">\n <v>\n 0\n </v>\n </c>\n \
|
|
||||||
<c t=\"n\">\n <v>17</v>\n </c>\n \
|
|
||||||
</row>\n </sheetData>\n</worksheet>";
|
|
||||||
let path = container(
|
|
||||||
"xlsx-indented",
|
|
||||||
"xlsx",
|
|
||||||
&[
|
|
||||||
("xl/sharedStrings.xml", shared),
|
|
||||||
("xl/worksheets/sheet1.xml", sheet),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let out = OfficeExtractor.extract(&path).expect("extract");
|
|
||||||
assert!(
|
|
||||||
out.text.contains("Marmalade"),
|
|
||||||
"the shared string was dropped by an indented index: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
out.text.contains("17"),
|
|
||||||
"the inline value was dropped: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
// The indentation itself is not content: every run of whitespace in
|
|
||||||
// the output should be a separator this reader put there, never a
|
|
||||||
// line of the source XML's own layout.
|
|
||||||
assert!(
|
|
||||||
!out.text.contains("\n "),
|
|
||||||
"sheet indentation reached the indexed text: {:?}",
|
|
||||||
out.text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supports_docx_and_friends() {
|
fn supports_docx_and_friends() {
|
||||||
let e = OfficeExtractor;
|
let e = OfficeExtractor;
|
||||||
|
|
@ -677,15 +428,6 @@ mod tests {
|
||||||
|
|
||||||
const XLSX_SHARED: &str = "<sst><si><t>Shared</t></si><si><t>Second</t></si></sst>";
|
const XLSX_SHARED: &str = "<sst><si><t>Shared</t></si><si><t>Second</t></si></sst>";
|
||||||
|
|
||||||
/// The first entry is `<si><t/></si>` — a blank cell, which every
|
|
||||||
/// spreadsheet writer emits. It still occupies index 0.
|
|
||||||
const XLSX_SHARED_WITH_BLANK: &str = "<sst><si><t/></si><si><t>Second</t></si></sst>";
|
|
||||||
|
|
||||||
/// One cell, referring to shared string index 1.
|
|
||||||
const XLSX_SHEET_INDEX_1: &str = "<worksheet><sheetData>\
|
|
||||||
<row><c t=\"s\"><v>1</v></c></row>\
|
|
||||||
</sheetData></worksheet>";
|
|
||||||
|
|
||||||
const XLSX_SHEET: &str = "<worksheet><sheetData>\
|
const XLSX_SHEET: &str = "<worksheet><sheetData>\
|
||||||
<row><c t=\"s\"><v>0</v></c><c t=\"n\"><v>42</v></c></row>\
|
<row><c t=\"s\"><v>0</v></c><c t=\"n\"><v>42</v></c></row>\
|
||||||
<row><c t=\"s\"><v>1</v></c></row>\
|
<row><c t=\"s\"><v>1</v></c></row>\
|
||||||
|
|
@ -858,83 +600,4 @@ mod tests {
|
||||||
crate::testutil::touch(&p, b"this is not a zip archive");
|
crate::testutil::touch(&p, b"this is not a zip archive");
|
||||||
assert!(extract_document_text(&p, "docx").is_err());
|
assert!(extract_document_text(&p, "docx").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A self-closed `<t/>` is a whole shared-string entry. Skip it and every
|
|
||||||
/// later index slides by one, so the sheet renders a real string for the
|
|
||||||
/// wrong cell — clean extraction, no error, wrong content. This is the
|
|
||||||
/// regression that made the batch worth doing.
|
|
||||||
#[test]
|
|
||||||
fn a_blank_shared_string_still_occupies_its_index() {
|
|
||||||
let p = container(
|
|
||||||
"xlsx-blank-si",
|
|
||||||
"xlsx",
|
|
||||||
&[
|
|
||||||
("xl/sharedStrings.xml", XLSX_SHARED_WITH_BLANK),
|
|
||||||
("xl/worksheets/sheet1.xml", XLSX_SHEET_INDEX_1),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
extract_document_text(&p, "xlsx").unwrap(),
|
|
||||||
"Second \n",
|
|
||||||
"index 1 must still be the second entry"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same shape one level up: a `<si>` holding nothing at all.
|
|
||||||
#[test]
|
|
||||||
fn an_empty_si_still_occupies_its_index() {
|
|
||||||
let p = container(
|
|
||||||
"xlsx-empty-si",
|
|
||||||
"xlsx",
|
|
||||||
&[
|
|
||||||
(
|
|
||||||
"xl/sharedStrings.xml",
|
|
||||||
"<sst><si><t></t></si><si><t>Second</t></si></sst>",
|
|
||||||
),
|
|
||||||
("xl/worksheets/sheet1.xml", XLSX_SHEET_INDEX_1),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(extract_document_text(&p, "xlsx").unwrap(), "Second \n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An entity splits its run into three events. The separator belongs to
|
|
||||||
/// the run, so the cell must read `A&B` — not `A &B`, and not `A & B`.
|
|
||||||
#[test]
|
|
||||||
fn an_entity_does_not_split_an_ods_cell() {
|
|
||||||
let body = "<office:document-content><office:body><office:spreadsheet>\
|
|
||||||
<table:table><table:table-row>\
|
|
||||||
<table:table-cell><text:p>A&B</text:p></table:table-cell>\
|
|
||||||
</table:table-row></table:table>\
|
|
||||||
</office:spreadsheet></office:body></office:document-content>";
|
|
||||||
let p = container("ods-entity", "ods", &[("content.xml", body)]);
|
|
||||||
assert_eq!(extract_document_text(&p, "ods").unwrap(), "A&B \n");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A span closing inside a paragraph ends the span, not the paragraph:
|
|
||||||
/// the text after it is body text and must be indexed.
|
|
||||||
#[test]
|
|
||||||
fn text_after_a_nested_span_is_not_dropped() {
|
|
||||||
let body = "<office:document-content><office:body><office:text>\
|
|
||||||
<text:p>before<text:span>inside</text:span>after</text:p>\
|
|
||||||
</office:text></office:body></office:document-content>";
|
|
||||||
let p = container("odt-span-tail", "odt", &[("content.xml", body)]);
|
|
||||||
assert_eq!(
|
|
||||||
extract_document_text(&p, "odt").unwrap(),
|
|
||||||
"beforeinsideafter\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ODF writes a blank line as a self-closed `<text:p/>`, which has no
|
|
||||||
/// `End` to hang the paragraph break on.
|
|
||||||
#[test]
|
|
||||||
fn a_self_closed_paragraph_still_breaks_the_line() {
|
|
||||||
let body = "<office:document-content><office:body><office:text>\
|
|
||||||
<text:p>first</text:p><text:p/><text:p>third</text:p>\
|
|
||||||
</office:text></office:body></office:document-content>";
|
|
||||||
let p = container("odt-empty-p", "odt", &[("content.xml", body)]);
|
|
||||||
assert_eq!(
|
|
||||||
extract_document_text(&p, "odt").unwrap(),
|
|
||||||
"first\n\nthird\n"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,31 +172,9 @@ mod doc {
|
||||||
.ok_or("CLX runs past the end of the table stream")?;
|
.ok_or("CLX runs past the end of the table stream")?;
|
||||||
let pieces = piece_table(clx)?;
|
let pieces = piece_table(clx)?;
|
||||||
|
|
||||||
let out = decode_pieces(&doc, &pieces, MAX_TEXT_BYTES);
|
|
||||||
if out.trim().is_empty() {
|
|
||||||
return Err("no text found in the piece table".into());
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode `pieces` out of the document stream, stopping at `budget`.
|
|
||||||
///
|
|
||||||
/// Two budgets, because the output one cannot bound the input. `clean`
|
|
||||||
/// drops the whole C0 range, so a piece of control bytes decodes at full
|
|
||||||
/// width and appends nothing — leaving a brake on `out.len()` that never
|
|
||||||
/// advances. And nothing requires pieces to be disjoint or ordered: each
|
|
||||||
/// names its own `fc`, so a table can point every piece at the same span,
|
|
||||||
/// and a 2 MiB file can name tens of GiB of decoding. Charging the bytes
|
|
||||||
/// actually read bounds both the repetition and the merely enormous
|
|
||||||
/// document.
|
|
||||||
///
|
|
||||||
/// `budget` is a parameter so a test can trip it without building a file
|
|
||||||
/// the size of the real one.
|
|
||||||
pub(super) fn decode_pieces(doc: &[u8], pieces: &[Piece], budget: usize) -> String {
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut decoded = 0usize;
|
|
||||||
for piece in pieces {
|
for piece in pieces {
|
||||||
if out.len() >= budget || decoded >= budget {
|
if out.len() >= MAX_TEXT_BYTES {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let Some(bytes) = doc.get(piece.start..piece.end) else {
|
let Some(bytes) = doc.get(piece.start..piece.end) else {
|
||||||
|
|
@ -205,7 +183,6 @@ mod doc {
|
||||||
// a recoverable document.
|
// a recoverable document.
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
decoded = decoded.saturating_add(bytes.len());
|
|
||||||
let text = if piece.compressed {
|
let text = if piece.compressed {
|
||||||
cp1252(bytes)
|
cp1252(bytes)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -213,7 +190,10 @@ mod doc {
|
||||||
};
|
};
|
||||||
clean(&text, &mut out);
|
clean(&text, &mut out);
|
||||||
}
|
}
|
||||||
out
|
if out.trim().is_empty() {
|
||||||
|
return Err("no text found in the piece table".into());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk the FIB's variable-length sections to find `fcClx`/`lcbClx`.
|
/// Walk the FIB's variable-length sections to find `fcClx`/`lcbClx`.
|
||||||
|
|
@ -243,10 +223,10 @@ mod doc {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One run of characters in the `WordDocument` stream.
|
/// One run of characters in the `WordDocument` stream.
|
||||||
pub(super) struct Piece {
|
struct Piece {
|
||||||
pub(super) start: usize,
|
start: usize,
|
||||||
pub(super) end: usize,
|
end: usize,
|
||||||
pub(super) compressed: bool,
|
compressed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the `Pcdt` inside the CLX and decode its `PlcPcd`.
|
/// Locate the `Pcdt` inside the CLX and decode its `PlcPcd`.
|
||||||
|
|
@ -353,40 +333,14 @@ mod xls {
|
||||||
let book = stream(cfb, "Workbook")
|
let book = stream(cfb, "Workbook")
|
||||||
.or_else(|| stream(cfb, "Book"))
|
.or_else(|| stream(cfb, "Book"))
|
||||||
.ok_or("no Workbook stream")?;
|
.ok_or("no Workbook stream")?;
|
||||||
extract_from_book(&book, MAX_TEXT_BYTES)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The workbook stream's text, stopping at `budget`. Split out from
|
let records = split_records(&book);
|
||||||
/// [`extract`] so a test can trip the budget without building a file the
|
|
||||||
/// size of the real one.
|
|
||||||
pub(super) fn extract_from_book(book: &[u8], budget: usize) -> Result<String, Box<dyn Error>> {
|
|
||||||
let records = split_records(book);
|
|
||||||
let strings = shared_strings(&records);
|
let strings = shared_strings(&records);
|
||||||
|
|
||||||
let out = decode_cells(&records, &strings, budget);
|
|
||||||
if out.trim().is_empty() {
|
|
||||||
return Err("workbook holds no readable cell text".into());
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render every cell-bearing record, stopping at `budget`.
|
|
||||||
///
|
|
||||||
/// The same two budgets as [`super::doc::decode_pieces`], for the same
|
|
||||||
/// reason: `clean` can consume a whole cell and emit nothing, so a
|
|
||||||
/// workbook whose shared strings are all control characters runs to the
|
|
||||||
/// end of its records with the output brake never advancing — and one
|
|
||||||
/// `LABELSST` is six bytes, so a small file holds a great many of them,
|
|
||||||
/// each free to name the same 64 KiB shared string.
|
|
||||||
///
|
|
||||||
/// `budget` is a parameter so a test can trip it without building a file
|
|
||||||
/// the size of the real one.
|
|
||||||
fn decode_cells(records: &[Record<'_>], strings: &[String], budget: usize) -> String {
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let mut row_open = false;
|
let mut row_open = false;
|
||||||
let mut decoded = 0usize;
|
for rec in &records {
|
||||||
for rec in records {
|
if out.len() >= MAX_TEXT_BYTES {
|
||||||
if out.len() >= budget || decoded >= budget {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let cell = match rec.id {
|
let cell = match rec.id {
|
||||||
|
|
@ -409,7 +363,6 @@ mod xls {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(text) = cell {
|
if let Some(text) = cell {
|
||||||
decoded = decoded.saturating_add(text.len());
|
|
||||||
clean(&text, &mut out);
|
clean(&text, &mut out);
|
||||||
out.push(' ');
|
out.push(' ');
|
||||||
row_open = true;
|
row_open = true;
|
||||||
|
|
@ -418,7 +371,10 @@ mod xls {
|
||||||
if row_open {
|
if row_open {
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out
|
if out.trim().is_empty() {
|
||||||
|
return Err("workbook holds no readable cell text".into());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Record<'a> {
|
struct Record<'a> {
|
||||||
|
|
|
||||||
|
|
@ -436,103 +436,3 @@ fn an_empty_file_is_an_error() {
|
||||||
crate::testutil::touch(&p, b"");
|
crate::testutil::touch(&p, b"");
|
||||||
assert!(extract_ole_text(&p, "xls").is_err());
|
assert!(extract_ole_text(&p, "xls").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- decode budgets ---------------------------------------------------
|
|
||||||
|
|
||||||
/// Nothing in the format requires pieces to be disjoint, so a piece table may
|
|
||||||
/// point every entry at the same span: a small file that decodes gigabytes.
|
|
||||||
/// `clean` drops the whole C0 range, so a run of control bytes produces no
|
|
||||||
/// output at all and a brake on the emitted text never fires. The budget has
|
|
||||||
/// to charge what was *read*.
|
|
||||||
#[test]
|
|
||||||
fn doc_overlapping_control_pieces_stop_at_the_budget() {
|
|
||||||
// One 4 KiB span of control bytes, pointed at over and over.
|
|
||||||
const SPAN: usize = 4 * 1024;
|
|
||||||
let mut doc = vec![0x01u8; SPAN];
|
|
||||||
let marker_at = doc.len();
|
|
||||||
doc.extend_from_slice(b"MARKER");
|
|
||||||
|
|
||||||
let mut pieces: Vec<doc::Piece> = (0..64)
|
|
||||||
.map(|_| doc::Piece {
|
|
||||||
start: 0,
|
|
||||||
end: SPAN,
|
|
||||||
compressed: true,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
// Reachable only if the budget did not stop the walk first.
|
|
||||||
pieces.push(doc::Piece {
|
|
||||||
start: marker_at,
|
|
||||||
end: doc.len(),
|
|
||||||
compressed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A budget of half what those pieces decode.
|
|
||||||
let out = doc::decode_pieces(&doc, &pieces, SPAN * 32);
|
|
||||||
assert!(
|
|
||||||
!out.contains("MARKER"),
|
|
||||||
"the walk ran past its budget: {out:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
out.trim().is_empty(),
|
|
||||||
"control bytes must not survive `clean`: {out:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same table under a budget it fits inside must be extracted whole —
|
|
||||||
/// the brake must not fire early.
|
|
||||||
#[test]
|
|
||||||
fn doc_pieces_within_the_budget_are_all_decoded() {
|
|
||||||
const SPAN: usize = 4 * 1024;
|
|
||||||
let mut doc = vec![0x01u8; SPAN];
|
|
||||||
let marker_at = doc.len();
|
|
||||||
doc.extend_from_slice(b"MARKER");
|
|
||||||
|
|
||||||
let mut pieces: Vec<doc::Piece> = (0..4)
|
|
||||||
.map(|_| doc::Piece {
|
|
||||||
start: 0,
|
|
||||||
end: SPAN,
|
|
||||||
compressed: true,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
pieces.push(doc::Piece {
|
|
||||||
start: marker_at,
|
|
||||||
end: doc.len(),
|
|
||||||
compressed: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let out = doc::decode_pieces(&doc, &pieces, SPAN * 32);
|
|
||||||
assert!(out.contains("MARKER"), "stopped early: {out:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A workbook whose cells are all control characters: every `LABELSST` is six
|
|
||||||
/// bytes of record and resolves to a shared string that `clean` erases, so the
|
|
||||||
/// emitted-text brake never advances however many of them there are.
|
|
||||||
#[test]
|
|
||||||
fn xls_control_character_cells_stop_at_the_budget() {
|
|
||||||
let control: String = std::iter::repeat('\u{1}').take(4096).collect();
|
|
||||||
|
|
||||||
let mut sst = Vec::new();
|
|
||||||
sst.extend_from_slice(&le32(2)); // total
|
|
||||||
sst.extend_from_slice(&le32(2)); // unique
|
|
||||||
sst.extend_from_slice(&sst_string(&control, false));
|
|
||||||
sst.extend_from_slice(&sst_string("MARKER", false));
|
|
||||||
|
|
||||||
let mut book = biff(xls::REC_SST, &sst);
|
|
||||||
for _ in 0..64 {
|
|
||||||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(0)));
|
|
||||||
}
|
|
||||||
// Reachable only if the budget did not stop the scan first.
|
|
||||||
book.extend_from_slice(&biff(xls::REC_LABELSST, &labelsst(1)));
|
|
||||||
|
|
||||||
let out = xls::extract_from_book(&book, 4096 * 32)
|
|
||||||
.unwrap_err()
|
|
||||||
.to_string();
|
|
||||||
assert!(
|
|
||||||
out.contains("no readable cell text"),
|
|
||||||
"expected the scan to stop before the marker, got: {out}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Under a budget it fits inside, the same workbook reads normally.
|
|
||||||
let out = xls::extract_from_book(&book, 4096 * 1024).unwrap();
|
|
||||||
assert!(out.contains("MARKER"), "stopped early: {out:?}");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -57,39 +57,6 @@ fn decode(bytes: Vec<u8>, path: &Path) -> Result<ExtractedContent, ExtractError>
|
||||||
|
|
||||||
pub struct PlaintextExtractor;
|
pub struct PlaintextExtractor;
|
||||||
|
|
||||||
/// Ceiling on a single read, whatever the file claims.
|
|
||||||
///
|
|
||||||
/// Not a policy about what is worth indexing — `maximum_text_file_size` is
|
|
||||||
/// that, and it is applied to the size the walk saw. This is the backstop for
|
|
||||||
/// the gap between those two moments, and for a node whose `fstat` lies.
|
|
||||||
const MAX_READ: usize = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Read `size` bytes from `f`, never more than `cap`.
|
|
||||||
///
|
|
||||||
/// The cap is the point of the function. The gate that got this file here is
|
|
||||||
/// `maximum_text_file_size` against the size the *walk* recorded, which on a
|
|
||||||
/// large tree was minutes or hours ago; `size` is from the `fstat` just now. A
|
|
||||||
/// file that grew since — a log, a cloud placeholder that hydrated — would
|
|
||||||
/// otherwise be allocated and read in full, and the decode afterwards can take
|
|
||||||
/// three times that again.
|
|
||||||
///
|
|
||||||
/// A short read is not an error: a file that shrank keeps its prefix.
|
|
||||||
fn read_sized(f: &mut File, size: usize, cap: usize, path: &Path) -> Result<Vec<u8>, ExtractError> {
|
|
||||||
let size = size.min(cap);
|
|
||||||
let mut buf = vec![0u8; size];
|
|
||||||
let mut filled = 0;
|
|
||||||
while filled < size {
|
|
||||||
match f.read(&mut buf[filled..]) {
|
|
||||||
Ok(0) => break,
|
|
||||||
Ok(n) => filled += n,
|
|
||||||
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
|
||||||
Err(e) => return Err(format!("plaintext read {}: {}", path.display(), e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buf.truncate(filled);
|
|
||||||
Ok(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Extractor for PlaintextExtractor {
|
impl Extractor for PlaintextExtractor {
|
||||||
fn supports(&self, mime: &str) -> bool {
|
fn supports(&self, mime: &str) -> bool {
|
||||||
mime.starts_with("text/") || EXTRA_TEXT_MIMES.contains(&mime)
|
mime.starts_with("text/") || EXTRA_TEXT_MIMES.contains(&mime)
|
||||||
|
|
@ -116,14 +83,26 @@ impl Extractor for PlaintextExtractor {
|
||||||
// before anyway. Capped so a node that streams forever (a FIFO, a
|
// before anyway. Capped so a node that streams forever (a FIFO, a
|
||||||
// lying filesystem) cannot allocate without bound.
|
// lying filesystem) cannot allocate without bound.
|
||||||
if size == 0 {
|
if size == 0 {
|
||||||
|
const MAX_UNSIZED_READ: u64 = 64 * 1024 * 1024;
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
f.take(MAX_READ as u64)
|
f.take(MAX_UNSIZED_READ)
|
||||||
.read_to_end(&mut buf)
|
.read_to_end(&mut buf)
|
||||||
.map_err(|e| format!("plaintext read {}: {}", path.display(), e))?;
|
.map_err(|e| format!("plaintext read {}: {}", path.display(), e))?;
|
||||||
return decode(buf, path);
|
return decode(buf, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
decode(read_sized(&mut f, size, MAX_READ, path)?, path)
|
let mut buf = vec![0u8; size];
|
||||||
|
let mut filled = 0;
|
||||||
|
while filled < size {
|
||||||
|
match f.read(&mut buf[filled..]) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => filled += n,
|
||||||
|
Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {}
|
||||||
|
Err(e) => return Err(format!("plaintext read {}: {}", path.display(), e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.truncate(filled);
|
||||||
|
decode(buf, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract_from_head(
|
fn extract_from_head(
|
||||||
|
|
@ -304,38 +283,4 @@ mod tests {
|
||||||
assert!(!e.supports("application/pdf"));
|
assert!(!e.supports("application/pdf"));
|
||||||
assert!(!e.supports("image/png"));
|
assert!(!e.supports("image/png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The cap is what stands between a file that grew since the walk sized
|
|
||||||
/// it — a log, a cloud placeholder that hydrated — and an allocation the
|
|
||||||
/// size of whatever it grew to.
|
|
||||||
#[test]
|
|
||||||
fn a_sized_read_stops_at_the_cap() {
|
|
||||||
let body = vec![b'x'; 4096];
|
|
||||||
let p = tmp("cap", &body);
|
|
||||||
let mut f = File::open(&p).unwrap();
|
|
||||||
let out = read_sized(&mut f, body.len(), 100, &p).unwrap();
|
|
||||||
assert_eq!(out.len(), 100, "read past the cap");
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file that *shrank* between the `fstat` and the read keeps its prefix
|
|
||||||
/// rather than failing: the next run reclassifies it anyway.
|
|
||||||
#[test]
|
|
||||||
fn a_short_read_keeps_what_was_there() {
|
|
||||||
let p = tmp("short", b"only ten!!");
|
|
||||||
let mut f = File::open(&p).unwrap();
|
|
||||||
let out = read_sized(&mut f, 1_000_000, MAX_READ, &p).unwrap();
|
|
||||||
assert_eq!(out, b"only ten!!");
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Under a cap larger than the file, nothing changes.
|
|
||||||
#[test]
|
|
||||||
fn a_file_under_the_cap_is_read_whole() {
|
|
||||||
let body = vec![b'y'; 4096];
|
|
||||||
let p = tmp("uncapped", &body);
|
|
||||||
let out = PlaintextExtractor.extract(&p).unwrap();
|
|
||||||
assert_eq!(out.text.len(), 4096);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,13 @@
|
||||||
//! plaintext extractor in [`super::Registry::default_set`], because
|
//! plaintext extractor in [`super::Registry::default_set`], because
|
||||||
//! plaintext claims every `text/*` and would otherwise swallow `text/rtf`
|
//! plaintext claims every `text/*` and would otherwise swallow `text/rtf`
|
||||||
//! and index the control-word noise raw.
|
//! and index the control-word noise raw.
|
||||||
//!
|
|
||||||
//! `rtf-parser` resolves to `vendor/rtf-parser`, a patched copy — its lexer
|
|
||||||
//! ended a control word at whitespace and nowhere else, which silently dropped
|
|
||||||
//! text from documents LibreOffice and Word produce. The `[patch.crates-io]`
|
|
||||||
//! note in the workspace manifest is where that is written up; the tests below
|
|
||||||
//! and `tests/extraction_corpus.rs` are what keep it fixed.
|
|
||||||
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::Read;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use rtf_parser::document::RtfDocument;
|
use rtf_parser::document::RtfDocument;
|
||||||
|
|
||||||
use super::{ExtractError, ExtractedContent, Extractor};
|
use super::{ExtractError, ExtractedContent, Extractor};
|
||||||
|
|
||||||
/// Ceiling on a single read; see [`super::plaintext`], same reasoning.
|
|
||||||
const MAX_READ: usize = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Parse a complete RTF file's bytes. Shared by both entry points so
|
/// Parse a complete RTF file's bytes. Shared by both entry points so
|
||||||
/// on-disk and already-in-memory extraction cannot drift apart.
|
/// on-disk and already-in-memory extraction cannot drift apart.
|
||||||
///
|
///
|
||||||
|
|
@ -40,28 +29,15 @@ fn parse(bytes: Vec<u8>, path: &Path) -> Result<ExtractedContent, ExtractError>
|
||||||
|
|
||||||
pub struct RtfExtractor;
|
pub struct RtfExtractor;
|
||||||
|
|
||||||
/// Read at most `cap` bytes of `path`.
|
|
||||||
///
|
|
||||||
/// Bounded rather than `fs::read`: the size gate that admitted this file was
|
|
||||||
/// applied to what the walk recorded, and the file may have grown since.
|
|
||||||
/// `rtf-parser` also amplifies its input several-fold in heap, so an unbounded
|
|
||||||
/// read here is unbounded twice over.
|
|
||||||
fn read_capped(path: &Path, cap: u64) -> Result<Vec<u8>, ExtractError> {
|
|
||||||
let file = File::open(path).map_err(|e| format!("rtf read {}: {}", path.display(), e))?;
|
|
||||||
let mut bytes = Vec::new();
|
|
||||||
file.take(cap)
|
|
||||||
.read_to_end(&mut bytes)
|
|
||||||
.map_err(|e| format!("rtf read {}: {}", path.display(), e))?;
|
|
||||||
Ok(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Extractor for RtfExtractor {
|
impl Extractor for RtfExtractor {
|
||||||
fn supports(&self, mime: &str) -> bool {
|
fn supports(&self, mime: &str) -> bool {
|
||||||
mime == "application/rtf" || mime == "text/rtf"
|
mime == "application/rtf" || mime == "text/rtf"
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extract(&self, path: &Path) -> Result<ExtractedContent, ExtractError> {
|
fn extract(&self, path: &Path) -> Result<ExtractedContent, ExtractError> {
|
||||||
parse(read_capped(path, MAX_READ as u64)?, path)
|
let bytes =
|
||||||
|
std::fs::read(path).map_err(|e| format!("rtf read {}: {}", path.display(), e))?;
|
||||||
|
parse(bytes, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RTF has no trailer and needs no seeking, so a head that is the whole
|
/// RTF has no trailer and needs no seeking, so a head that is the whole
|
||||||
|
|
@ -107,29 +83,19 @@ mod tests {
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `\\u` escape naming a lone UTF-16 surrogate costs one character, not
|
/// A `\\u` escape naming a lone UTF-16 surrogate must fail the file, not
|
||||||
/// the document and not the thread.
|
/// the thread.
|
||||||
///
|
///
|
||||||
/// `rtf-parser` reached `String::from_utf16(..).unwrap()` with whatever
|
/// `rtf-parser` reaches `String::from_utf16(..).unwrap()` with whatever
|
||||||
/// `\\uN` supplied and screened nothing for the surrogate range, so a
|
/// `\\uN` supplied, and screens nothing for the surrogate range. RTF is one
|
||||||
/// fifteen-byte document could panic. RTF is one of the two extractors that
|
/// of the two extractors that also run at *walk* time, off
|
||||||
/// also run at *walk* time, off `extract_from_head`, where a panicking
|
/// `extract_from_head`, where a panicking worker costs the root its entire
|
||||||
/// worker costs the root its entire content pass and disables stale
|
/// content pass and disables stale cleanup run-wide — so this is contained
|
||||||
/// cleanup run-wide — so the panic was contained in `decide_content` and
|
/// in `decide_content` and `prepare_file_record` rather than left to the
|
||||||
/// `prepare_file_record`, and the file recorded as FAILED.
|
/// parser. Both entry points are exercised here.
|
||||||
///
|
|
||||||
/// `vendor/rtf-parser` decodes lossily instead (LOCAL PATCH, see
|
|
||||||
/// `Parser::flush_unicode`), which beats either outcome: the bad escape
|
|
||||||
/// becomes one `U+FFFD` and the rest of the document is indexed. Both
|
|
||||||
/// entry points are still exercised, because the containment above them
|
|
||||||
/// has to keep working for every other way a parser can panic.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_lone_surrogate_escape_costs_one_character() {
|
fn a_lone_surrogate_escape_is_contained() {
|
||||||
// `\u55296` is a high surrogate with no low half to follow it. The `?`
|
let body = br"{\rtf1\u55296 }";
|
||||||
// is its ANSI fallback, written the way a real producer writes one —
|
|
||||||
// spelled with a space delimiter instead, the `a` of `after` would be
|
|
||||||
// the fallback and would correctly be eaten.
|
|
||||||
let body = "{\\rtf1\\ansi before \\u55296?after}".as_bytes();
|
|
||||||
let p = tmp("surrogate", body);
|
let p = tmp("surrogate", body);
|
||||||
|
|
||||||
// The on-disk path, as the content pass reaches it.
|
// The on-disk path, as the content pass reaches it.
|
||||||
|
|
@ -139,53 +105,28 @@ mod tests {
|
||||||
&crate::extract::Registry::default_set(),
|
&crate::extract::Registry::default_set(),
|
||||||
&crate::config::Config::default(),
|
&crate::config::Config::default(),
|
||||||
);
|
);
|
||||||
let text = match &outcome {
|
|
||||||
crate::file_handling::ContentOutcome::Done { text } => text.clone(),
|
|
||||||
other => panic!("a malformed escape must not fail the document: {other:?}"),
|
|
||||||
};
|
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("before") && text.contains("after"),
|
matches!(outcome, crate::file_handling::ContentOutcome::Failed(_)),
|
||||||
"the rest of the document must survive: {text:?}"
|
"a panicking parser must record a failure, not unwind: {:?}",
|
||||||
);
|
outcome
|
||||||
assert!(
|
|
||||||
text.contains('\u{FFFD}'),
|
|
||||||
"the bad escape must leave a replacement character: {text:?}"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// And the head path, as a walk worker reaches it: through the registry,
|
// And the head path, as a walk worker reaches it: through the
|
||||||
// which is where the containment for any *other* panicking input lives.
|
// registry, which is where the containment lives. The raw
|
||||||
|
// `RtfExtractor::extract_from_head` below it still panics — that is
|
||||||
|
// third-party code doing what it does, and the point is that no
|
||||||
|
// caller in this crate is exposed to it.
|
||||||
let head = crate::extract::Registry::default_set().extract_complete_head(
|
let head = crate::extract::Registry::default_set().extract_complete_head(
|
||||||
&p,
|
&p,
|
||||||
"application/rtf",
|
"application/rtf",
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
head.expect("claimed").expect("parsed").text,
|
|
||||||
text,
|
|
||||||
"head and disk extraction must agree"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `\\par` ends a paragraph, so it has to reach the text as a line break.
|
|
||||||
///
|
|
||||||
/// It used to emit nothing, and every paragraph boundary closed up:
|
|
||||||
/// a LibreOffice document came back as `...do eiusmod.The needle...`.
|
|
||||||
/// No text was lost, but the join invents word and sentence boundaries
|
|
||||||
/// that are not in the document — which a snippet then shows to the user,
|
|
||||||
/// and which a phrase query can match across. Fixed in
|
|
||||||
/// `vendor/rtf-parser` (LOCAL PATCH), alongside `\\line`, which always
|
|
||||||
/// did the right thing.
|
|
||||||
#[test]
|
|
||||||
fn paragraph_breaks_reach_the_text() {
|
|
||||||
let body = br"{\rtf1\ansi First paragraph.\par Second paragraph.\par}";
|
|
||||||
let p = tmp("par", body);
|
|
||||||
let text = RtfExtractor.extract(&p).unwrap().text;
|
|
||||||
assert!(
|
assert!(
|
||||||
text.contains("First paragraph.\nSecond paragraph."),
|
matches!(head, Some(Err(_))),
|
||||||
"paragraphs ran together: {text:?}"
|
"a panicking parser must be charged to the file, not the worker: {:?}",
|
||||||
|
head.map(|r| r.map(|c| c.text))
|
||||||
);
|
);
|
||||||
|
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,30 +152,4 @@ mod tests {
|
||||||
assert!(!e.supports("text/plain"));
|
assert!(!e.supports("text/plain"));
|
||||||
assert!(!e.supports("application/pdf"));
|
assert!(!e.supports("application/pdf"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `rtf-parser` amplifies its input several-fold in heap, so the read that
|
|
||||||
/// feeds it has to be bounded independently of what the walk recorded.
|
|
||||||
#[test]
|
|
||||||
fn a_read_stops_at_the_cap() {
|
|
||||||
let body = vec![b'x'; 4096];
|
|
||||||
let p = tmp("cap", &body);
|
|
||||||
assert_eq!(
|
|
||||||
read_capped(&p, 100).unwrap().len(),
|
|
||||||
100,
|
|
||||||
"read past the cap"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
read_capped(&p, MAX_READ as u64).unwrap().len(),
|
|
||||||
4096,
|
|
||||||
"a file under the cap must be read whole"
|
|
||||||
);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_missing_file_is_an_error_naming_it() {
|
|
||||||
let p = crate::testutil::scratch_dir("rtf-missing").join("nope.rtf");
|
|
||||||
let err = read_capped(&p, MAX_READ as u64).unwrap_err();
|
|
||||||
assert!(err.contains(&p.display().to_string()), "{err}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,8 +104,7 @@ pub fn process_batch_updates(
|
||||||
let updated = repo::update_file_basic(&tx, &rec.as_new_file()).map_err(|e| {
|
let updated = repo::update_file_basic(&tx, &rec.as_new_file()).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to update file record + clear stale content for {}: {}",
|
"Failed to update file record + clear stale content for {}: {}",
|
||||||
rec.path(),
|
rec.path, e
|
||||||
e
|
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|
@ -116,7 +115,7 @@ pub fn process_batch_updates(
|
||||||
None => {
|
None => {
|
||||||
crate::log_warn!(
|
crate::log_warn!(
|
||||||
"no indexed row matched {} during update; inserting instead",
|
"no indexed row matched {} during update; inserting instead",
|
||||||
rec.path()
|
rec.path
|
||||||
);
|
);
|
||||||
repo::insert_file(&tx, &rec.as_new_file())
|
repo::insert_file(&tx, &rec.as_new_file())
|
||||||
.map_err(|e| format!("Failed to insert file record: {}", e))?
|
.map_err(|e| format!("Failed to insert file record: {}", e))?
|
||||||
|
|
@ -125,13 +124,8 @@ pub fn process_batch_updates(
|
||||||
};
|
};
|
||||||
|
|
||||||
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
|
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
|
||||||
let zstd = body_or_skip!(bodies, i, rec.path());
|
let zstd = body_or_skip!(bodies, i, rec.path);
|
||||||
// `_fresh`: `update_file_basic` above cleared this row's
|
repo::set_content_done(&tx, id, text, zstd)?;
|
||||||
// content in this same transaction, and the insert fallback
|
|
||||||
// created the row outright. Either way there is nothing left
|
|
||||||
// to delete, and the ordinary entry point would issue two
|
|
||||||
// statements per row to discover that.
|
|
||||||
repo::set_content_done_fresh(&tx, id, text, zstd)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -155,11 +149,7 @@ pub fn process_batch_inserts(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// `.max(1)`, as at every other use of this field: `chunks(0)` panics,
|
for batch in files_to_insert.chunks(config.processing.batch_size) {
|
||||||
// and a panic here is on the indexing thread, before the arm that would
|
|
||||||
// publish `IndexingStatus::Error` — so a hand-edited `batch_size = 0`
|
|
||||||
// wedges indexing for the session while the UI still reads "Running".
|
|
||||||
for batch in files_to_insert.chunks(config.processing.batch_size.max(1)) {
|
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
if stop_flag.load(Ordering::Relaxed) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -180,10 +170,8 @@ pub fn process_batch_inserts(
|
||||||
let id = repo::insert_file(&tx, &rec.as_new_file())
|
let id = repo::insert_file(&tx, &rec.as_new_file())
|
||||||
.map_err(|e| format!("Failed to insert file record: {}", e))?;
|
.map_err(|e| format!("Failed to insert file record: {}", e))?;
|
||||||
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
|
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
|
||||||
let zstd = body_or_skip!(bodies, i, rec.path());
|
let zstd = body_or_skip!(bodies, i, rec.path);
|
||||||
// `_fresh`: `insert_file` returned `Some` only by creating this
|
repo::set_content_done(&tx, id, text, zstd)?;
|
||||||
// row, so it cannot carry content from anywhere.
|
|
||||||
repo::set_content_done_fresh(&tx, id, text, zstd)?;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,19 +236,13 @@ pub fn cleanup_stale_index_entries(
|
||||||
|
|
||||||
/// Keyset cursor bounding everything stored beneath one directory.
|
/// Keyset cursor bounding everything stored beneath one directory.
|
||||||
///
|
///
|
||||||
/// `lo`/`hi` are the half-open range `[dir + SEP, dir + (SEP + 1))` over
|
/// `lo`/`hi` are the half-open path range `[dir + SEP, dir + (SEP + 1))`, so
|
||||||
/// `files.parent`, so the pair is a pure index range on `idx_files_parent`.
|
/// the pair is a pure index range on `UNIQUE(files.path)`.
|
||||||
///
|
///
|
||||||
/// It covers `dir`'s own files as well as its subdirectories' because every
|
/// The separator must be the platform's own: `files.path` stores native
|
||||||
/// stored parent ends in a separator (see `dir_to_db_parent`): the files
|
/// separators, and the successor of `/` (`0x2F`) is `'0'` while the successor
|
||||||
/// directly in `dir` have parent `dir + SEP`, which is `lo` exactly. Without
|
/// of `\` (`0x5C`) is `']'` — the Unix pair on Windows yields
|
||||||
/// that invariant `lo` would have to be the bare `dir`, and the range would
|
/// `hi = "C:\Users\me0"`, which every stored path sorts *above*, silently
|
||||||
/// swallow siblings — `/a-b` sorts inside `["/a", "/a0")`.
|
|
||||||
///
|
|
||||||
/// The separator must be the platform's own: parents store native separators,
|
|
||||||
/// and the successor of `/` (`0x2F`) is `'0'` while the successor of `\`
|
|
||||||
/// (`0x5C`) is `']'` — the Unix pair on Windows yields
|
|
||||||
/// `hi = "C:\Users\me0"`, which every stored parent sorts *above*, silently
|
|
||||||
/// disabling content extraction and the vanished-directory sweep.
|
/// disabling content extraction and the vanished-directory sweep.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ExtractCursor {
|
pub struct ExtractCursor {
|
||||||
|
|
@ -310,36 +292,14 @@ pub(crate) fn max_text_file_size(config: &Config) -> i64 {
|
||||||
/// *lowered* between runs (which does not force a rebuild), and rows left
|
/// *lowered* between runs (which does not force a rebuild), and rows left
|
||||||
/// pending by an older build. Rows this misses would stay pending forever, so
|
/// pending by an older build. Rows this misses would stay pending forever, so
|
||||||
/// it runs on the writer before a root's content pass starts.
|
/// it runs on the writer before a root's content pass starts.
|
||||||
///
|
|
||||||
/// `INDEXED BY`, for the same reason [`crate::db::repo::pending_content_page`]
|
|
||||||
/// spells its own out — and it is the sibling statement to that one, left
|
|
||||||
/// behind when the counting scan was moved off the writer. The planner takes
|
|
||||||
/// `idx_files_parent` for the range and then fetches **every table row in it**
|
|
||||||
/// to test `content_state`, which is a full scan of the root on the writer
|
|
||||||
/// thread, once per root per run, while every other root's walk waits. The
|
|
||||||
/// partial index holds only pending rows, so it answers the predicate without
|
|
||||||
/// touching anything else.
|
|
||||||
///
|
|
||||||
/// Measured on 50,000 rows, best of five:
|
|
||||||
///
|
|
||||||
/// | shape | planner's choice | `INDEXED BY` |
|
|
||||||
/// |---|---:|---:|
|
|
||||||
/// | re-index, 50 rows pending | 8.01 ms | **9.61 µs** |
|
|
||||||
/// | first index, all pending, two roots | 4.36 ms | **1.28 ms** |
|
|
||||||
///
|
|
||||||
/// The second row is the case this could have lost: with everything pending the
|
|
||||||
/// partial index covers *every* root, not just this one, so it scans rows the
|
|
||||||
/// range predicate then rejects. It still wins by 3.4x, because the index is
|
|
||||||
/// narrow and id-ordered where the range path has to fetch a full row per
|
|
||||||
/// entry. There is no shape in which the planner's choice is the better one.
|
|
||||||
pub fn mark_oversize_pending_na(
|
pub fn mark_oversize_pending_na(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
cursor: &ExtractCursor,
|
cursor: &ExtractCursor,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE files INDEXED BY idx_files_content_pending SET content_state = 3 \
|
"UPDATE files SET content_state = 3 \
|
||||||
WHERE content_state = 0 AND size > ?1 AND parent >= ?2 AND parent < ?3",
|
WHERE content_state = 0 AND size > ?1 AND path >= ?2 AND path < ?3",
|
||||||
rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi],
|
rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi],
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("mark oversize files NA: {}", e))?;
|
.map_err(|e| format!("mark oversize files NA: {}", e))?;
|
||||||
|
|
@ -362,7 +322,7 @@ pub fn count_extract_scope(
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COALESCE(SUM(content_state = 0 AND size <= ?1), 0), \
|
"SELECT COALESCE(SUM(content_state = 0 AND size <= ?1), 0), \
|
||||||
COALESCE(SUM(content_state = 1), 0) \
|
COALESCE(SUM(content_state = 1), 0) \
|
||||||
FROM files WHERE parent >= ?2 AND parent < ?3",
|
FROM files WHERE path >= ?2 AND path < ?3",
|
||||||
rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi],
|
rusqlite::params![max_text_file_size(config), cursor.lo, cursor.hi],
|
||||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -277,34 +277,3 @@ fn extract_scope_counts_only_files_an_extractor_claims() {
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
std::fs::remove_file(&db).ok();
|
std::fs::remove_file(&db).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `find` that fails writes nothing, and `wc -l` then reads EOF, prints `0`
|
|
||||||
/// and exits *successfully* — so checking only the last process in the
|
|
||||||
/// pipeline reported a tree of zero entries. That answer is indistinguishable
|
|
||||||
/// from a real empty tree, and it is what kept the non-GNU `-printf` fallback
|
|
||||||
/// from ever running: it is an `.or_else` on the error this used to swallow.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_failing_find_is_an_error_not_a_count_of_zero() {
|
|
||||||
let missing = tmp("count-missing").join("no-such-tree");
|
|
||||||
let cancel = AtomicBool::new(false);
|
|
||||||
let result = count_tree_entries_fast(missing.to_str().unwrap(), &cancel);
|
|
||||||
assert!(
|
|
||||||
result.is_err(),
|
|
||||||
"a path that does not exist counted {:?} entries",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An empty directory really does count zero (one entry on Unix, the root
|
|
||||||
/// `find` lists itself) — the error above must not have been bought by
|
|
||||||
/// calling every small answer a failure.
|
|
||||||
#[test]
|
|
||||||
fn an_empty_tree_still_counts() {
|
|
||||||
let root = tmp("count-empty");
|
|
||||||
std::fs::create_dir_all(&root).unwrap();
|
|
||||||
let cancel = AtomicBool::new(false);
|
|
||||||
let n = count_tree_entries_fast(root.to_str().unwrap(), &cancel).unwrap();
|
|
||||||
assert_eq!(n, if cfg!(windows) { 0 } else { 1 });
|
|
||||||
std::fs::remove_dir_all(&root).ok();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -25,31 +25,6 @@ const COUNT_POLL_MS: u64 = 50;
|
||||||
/// `cancel`: on cancellation every process in `children` is killed and a
|
/// `cancel`: on cancellation every process in `children` is killed and a
|
||||||
/// recognizable error is returned. On normal exit, returns the terminal
|
/// recognizable error is returned. On normal exit, returns the terminal
|
||||||
/// child's stdout.
|
/// child's stdout.
|
||||||
/// Wait on every member of the pipeline and report the first failure.
|
|
||||||
///
|
|
||||||
/// `terminal_status` is the one already collected by the caller; the rest are
|
|
||||||
/// waited on here. Returns the status of a member that failed, or `None` when
|
|
||||||
/// all of them succeeded.
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn reap_all(
|
|
||||||
children: &mut [&mut std::process::Child],
|
|
||||||
terminal_status: std::process::ExitStatus,
|
|
||||||
) -> Option<std::process::ExitStatus> {
|
|
||||||
let mut failed = (!terminal_status.success()).then_some(terminal_status);
|
|
||||||
let last = children.len().saturating_sub(1);
|
|
||||||
for (i, child) in children.iter_mut().enumerate() {
|
|
||||||
if i == last {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(status) = child.wait() {
|
|
||||||
if !status.success() && failed.is_none() {
|
|
||||||
failed = Some(status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
failed
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn wait_pipeline_cancellable(
|
fn wait_pipeline_cancellable(
|
||||||
children: &mut [&mut std::process::Child],
|
children: &mut [&mut std::process::Child],
|
||||||
|
|
@ -69,6 +44,9 @@ fn wait_pipeline_cancellable(
|
||||||
};
|
};
|
||||||
match terminal.try_wait() {
|
match terminal.try_wait() {
|
||||||
Ok(Some(status)) => {
|
Ok(Some(status)) => {
|
||||||
|
if !status.success() {
|
||||||
|
return Err(format!("count pipeline exited with {}", status));
|
||||||
|
}
|
||||||
// The terminal child's output is a couple dozen bytes
|
// The terminal child's output is a couple dozen bytes
|
||||||
// (a `wc -l` figure), so reading after exit can't deadlock.
|
// (a `wc -l` figure), so reading after exit can't deadlock.
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
|
|
@ -77,28 +55,14 @@ fn wait_pipeline_cancellable(
|
||||||
let mut stdout = stdout;
|
let mut stdout = stdout;
|
||||||
let _ = stdout.read_to_end(&mut out);
|
let _ = stdout.read_to_end(&mut out);
|
||||||
}
|
}
|
||||||
// Every member, not just the terminal — and before deciding.
|
// Reap the rest of the pipeline.
|
||||||
// A `find` that fails writes nothing and exits non-zero, while
|
for child in children.iter_mut() {
|
||||||
// `wc -l` reads EOF, prints `0` and exits *successfully*: read
|
let _ = child.wait();
|
||||||
// through the terminal alone that is a tree of zero files, and
|
|
||||||
// the `-printf` fallback keyed on this returning `Err` never
|
|
||||||
// runs at all. Reaping here also covers the failure paths,
|
|
||||||
// which used to return leaving `find` a zombie for the life of
|
|
||||||
// the process.
|
|
||||||
let failed = reap_all(children, status);
|
|
||||||
if let Some(bad) = failed {
|
|
||||||
return Err(format!("count pipeline exited with {}", bad));
|
|
||||||
}
|
}
|
||||||
return Ok(out);
|
return Ok(out);
|
||||||
}
|
}
|
||||||
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(COUNT_POLL_MS)),
|
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(COUNT_POLL_MS)),
|
||||||
Err(e) => {
|
Err(e) => return Err(format!("count wait: {}", e)),
|
||||||
for child in children.iter_mut() {
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
return Err(format!("count wait: {}", e));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,12 +74,7 @@ fn count_find_pipe_wc(
|
||||||
printf_newlines: bool,
|
printf_newlines: bool,
|
||||||
) -> Result<usize, String> {
|
) -> Result<usize, String> {
|
||||||
let mut find_cmd = Command::new("find");
|
let mut find_cmd = Command::new("find");
|
||||||
// `--` before the path, though nothing can currently reach here with a
|
find_cmd.arg(path);
|
||||||
// leading `-`: every root goes through `resolved_indexing_paths`, which
|
|
||||||
// absolutizes it, and then `normalize_root_string`, which canonicalizes.
|
|
||||||
// It is one token, and it keeps that reasoning from being load-bearing
|
|
||||||
// for whoever changes root resolution next.
|
|
||||||
find_cmd.arg("--").arg(path);
|
|
||||||
if printf_newlines {
|
if printf_newlines {
|
||||||
// GNU find: emit one newline per entry without formatting paths.
|
// GNU find: emit one newline per entry without formatting paths.
|
||||||
find_cmd.arg("-printf").arg("\n");
|
find_cmd.arg("-printf").arg("\n");
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,11 @@ pub use batch::{
|
||||||
Stored,
|
Stored,
|
||||||
};
|
};
|
||||||
pub use counting::count_tree_entries_fast;
|
pub use counting::count_tree_entries_fast;
|
||||||
pub use paths::{
|
pub use paths::{db_key_for_missing_path, filtered_dirs, filtered_walk, UnreadableDirs};
|
||||||
db_key_for_missing_path, filtered_dirs, filtered_walk, split_db_path, UnreadableDirs,
|
pub(crate) use paths::{normalize_root_string, path_to_db_string, warn_if_unrepresentable};
|
||||||
};
|
|
||||||
pub(crate) use paths::{
|
|
||||||
dir_to_db_parent, index_file_set, normalize_root_string, path_to_db_string,
|
|
||||||
warn_if_unrepresentable, INDEX_SIDECAR_SUFFIXES,
|
|
||||||
};
|
|
||||||
pub use records::{
|
pub use records::{
|
||||||
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
|
classify_by_mtime, classify_for_indexing, content_extractable, decide_content,
|
||||||
extract_and_store, fts_begin_bulk_write, fts_finalize_after_text_indexing,
|
extract_and_store, fts_finalize_after_text_indexing, hash_failure_counts, outcome_body,
|
||||||
fts_set_automerge, hash_failure_counts, outcome_body,
|
|
||||||
prepare_file_record, prepare_file_record_from_path, reset_run_warnings, store_content_outcome,
|
prepare_file_record, prepare_file_record_from_path, reset_run_warnings, store_content_outcome,
|
||||||
ContentOutcome, DirRows, FileIndexAction, OwnedNewFile,
|
ContentOutcome, DirRows, FileIndexAction, OwnedNewFile,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
//! Path ↔ `files.parent`/`files.name` string normalization and the filtered
|
//! Path ↔ `files.path` string normalization and the filtered walkdir
|
||||||
//! walkdir wrappers the reconcile passes use.
|
//! wrappers the reconcile passes use.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
|
@ -9,8 +8,7 @@ use walkdir::{DirEntry, WalkDir};
|
||||||
|
|
||||||
use crate::config::IgnoreSet;
|
use crate::config::IgnoreSet;
|
||||||
|
|
||||||
/// Render a path in the spelling the index stores it in, which
|
/// Render a path as the string stored in `files.path`.
|
||||||
/// [`split_db_path`] then splits into the `(parent, name)` key.
|
|
||||||
///
|
///
|
||||||
/// `Path::canonicalize` on Windows hands back extended-length paths; the
|
/// `Path::canonicalize` on Windows hands back extended-length paths; the
|
||||||
/// index stores plain ones. A UNC share canonicalizes to
|
/// index stores plain ones. A UNC share canonicalizes to
|
||||||
|
|
@ -36,55 +34,11 @@ pub(crate) fn path_to_db_string(path: &Path) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What SQLite and we hang off the index's own filename.
|
|
||||||
///
|
|
||||||
/// `-wal`, `-shm` and `-journal` are SQLite's, spelled exactly as
|
|
||||||
/// `db::open` and the unlock screen already delete them; `.lock` is
|
|
||||||
/// [`crate::platform::IndexLock`]'s.
|
|
||||||
pub(crate) const INDEX_SIDECAR_SUFFIXES: [&str; 4] = ["-wal", "-shm", "-journal", ".lock"];
|
|
||||||
|
|
||||||
/// Every file belonging to the index at `db_path`, spelled the way the walk
|
|
||||||
/// spells the files it visits.
|
|
||||||
///
|
|
||||||
/// **Nothing may ever open one of these.** On POSIX, closing *any* descriptor
|
|
||||||
/// on an inode cancels every advisory lock the whole process holds on it, so a
|
|
||||||
/// walk worker that opens `index.sqlite-shm` to hash it destroys the DMS lock
|
|
||||||
/// SQLite took on that file — after which the next connection to attach, from
|
|
||||||
/// any process, truncates the wal-index to 3 bytes under our live mapping and
|
|
||||||
/// the next commit dies with SIGBUS. The same close cancels the main
|
|
||||||
/// database's own locks, which is SQLite's documented corruption hazard
|
|
||||||
/// (howtocorrupt.html §2.2). See [`crate::walk`], which prunes these before an
|
|
||||||
/// entry can become a candidate.
|
|
||||||
///
|
|
||||||
/// The *directory* is canonicalized rather than the files: `-wal`, `-shm` and
|
|
||||||
/// the lock come and go across a run, and `canonicalize` fails on a path that
|
|
||||||
/// is not there at the instant it is called. The directory outlives all of
|
|
||||||
/// them, so resolving it once and joining the names gives a stable answer that
|
|
||||||
/// does not depend on which sidecars happen to exist.
|
|
||||||
pub(crate) fn index_file_set(db_path: &Path) -> HashSet<PathBuf> {
|
|
||||||
let mut set = HashSet::new();
|
|
||||||
// An empty filename means `database_path` names a directory, not a file.
|
|
||||||
// Joining "" onto the parent would yield the directory itself and prune
|
|
||||||
// the entire tree below it.
|
|
||||||
let Some(name) = db_path.file_name().and_then(|s| s.to_str()) else {
|
|
||||||
return set;
|
|
||||||
};
|
|
||||||
let dir = db_path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
let dir = PathBuf::from(path_to_db_string(
|
|
||||||
&dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()),
|
|
||||||
));
|
|
||||||
set.insert(dir.join(name));
|
|
||||||
for suffix in INDEX_SIDECAR_SUFFIXES {
|
|
||||||
set.insert(dir.join(format!("{}{}", name, suffix)));
|
|
||||||
}
|
|
||||||
set
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonicalize a root string for storage/comparison. Multi-root strings
|
/// Canonicalize a root string for storage/comparison. Multi-root strings
|
||||||
/// (newline-joined) fail canonicalize and pass through verbatim, which still
|
/// (newline-joined) fail canonicalize and pass through verbatim, which still
|
||||||
/// compares consistently.
|
/// compares consistently.
|
||||||
///
|
///
|
||||||
/// This is the spelling stored parents are prefixed with, so it is also the
|
/// This is the spelling `files.path` rows are prefixed with, so it is also the
|
||||||
/// form roots must be compared in: `~/docs` and `/home/me/docs` name one root
|
/// form roots must be compared in: `~/docs` and `/home/me/docs` name one root
|
||||||
/// and must not read as a change.
|
/// and must not read as a change.
|
||||||
pub(crate) fn normalize_root_string(indexing_path: &str) -> String {
|
pub(crate) fn normalize_root_string(indexing_path: &str) -> String {
|
||||||
|
|
@ -95,20 +49,11 @@ pub(crate) fn normalize_root_string(indexing_path: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Warn and report `true` for a path that cannot round-trip through
|
/// Warn and report `true` for a path that cannot round-trip through
|
||||||
/// the index.
|
/// `files.path`.
|
||||||
///
|
///
|
||||||
/// Everything downstream reopens the file by that TEXT column, and
|
/// Everything downstream reopens the file by that TEXT column, and
|
||||||
/// [`path_to_db_string`] is lossy: a non-UTF-8 name would be stored as a path
|
/// [`path_to_db_string`] is lossy: a non-UTF-8 name would be stored as a
|
||||||
/// naming a *different* file — U+FFFD is an ordinary filename character, so
|
/// path naming a file that does not exist. Such a file is skipped whole.
|
||||||
/// the lossy spelling is somebody's real name. Such a file is skipped whole.
|
|
||||||
///
|
|
||||||
/// This is the check for callers that arrive with a single path and no
|
|
||||||
/// listing to prune: [`prepare_file_record_from_path`] on the incremental
|
|
||||||
/// route. The full walk screens far earlier, on the directory entry itself
|
|
||||||
/// (`crate::walk::read_directory`), because by the time a path exists a bad
|
|
||||||
/// *directory* component has already been joined into every path beneath it.
|
|
||||||
///
|
|
||||||
/// [`prepare_file_record_from_path`]: super::prepare_file_record_from_path
|
|
||||||
pub(crate) fn warn_if_unrepresentable(path: &Path) -> bool {
|
pub(crate) fn warn_if_unrepresentable(path: &Path) -> bool {
|
||||||
if path.to_str().is_some() {
|
if path.to_str().is_some() {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -128,21 +73,13 @@ fn starts_with_drive_letter(s: &str) -> bool {
|
||||||
matches!((it.next(), it.next()), (Some(c), Some(':')) if c.is_ascii_alphabetic())
|
matches!((it.next(), it.next()), (Some(c), Some(':')) if c.is_ascii_alphabetic())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The stored spelling of a path that may no longer exist.
|
/// The `files.path` key for a path that may no longer exist.
|
||||||
///
|
///
|
||||||
/// The insert side canonicalizes before storing, and plain `canonicalize`
|
/// The insert side canonicalizes before storing, and plain `canonicalize`
|
||||||
/// fails on a path already gone — so this canonicalizes the deepest ancestor
|
/// fails on a path already gone — so this canonicalizes the deepest ancestor
|
||||||
/// that still resolves and re-joins the missing tail. On Linux a root reached
|
/// that still resolves and re-joins the missing tail. On Linux a root reached
|
||||||
/// through a symlinked parent (`/home` → `/mnt/home`) makes every removal a
|
/// through a symlinked parent (`/home` → `/mnt/home`) makes every removal a
|
||||||
/// no-op without this.
|
/// no-op without this.
|
||||||
///
|
|
||||||
/// **The caller must have screened `path` for representability.** This ends in
|
|
||||||
/// [`path_to_db_string`], which is lossy, and the answer is used to *delete* —
|
|
||||||
/// a row by path and then a whole subtree range beneath it. For a path that is
|
|
||||||
/// not valid UTF-8 the key returned names some other, real file, and deleting
|
|
||||||
/// by it takes that file's row and everything under it. There is nothing to
|
|
||||||
/// delete for such a path in any case: it could never have been indexed. See
|
|
||||||
/// [`crate::incremental::remove_paths`], which skips them.
|
|
||||||
pub fn db_key_for_missing_path(path: &Path) -> String {
|
pub fn db_key_for_missing_path(path: &Path) -> String {
|
||||||
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
let mut tail: Vec<std::ffi::OsString> = Vec::new();
|
||||||
let mut cursor = path;
|
let mut cursor = path;
|
||||||
|
|
@ -167,42 +104,12 @@ pub fn db_key_for_missing_path(path: &Path) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a directory as the string stored in `files.parent`.
|
/// Parent directory of a path as a UTF-8 string, empty if root.
|
||||||
///
|
pub(super) fn parent_str(path: &str) -> String {
|
||||||
/// **`files.parent` always ends in the platform separator**, and that is the
|
Path::new(path)
|
||||||
/// invariant the whole schema rests on:
|
.parent()
|
||||||
///
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
/// * A file's path is `parent` concatenated with `name` — no separator logic
|
.unwrap_or_default()
|
||||||
/// at the join, and so no special case for `/` or `C:\`, whose children
|
|
||||||
/// would otherwise be spelled `//x` and `C:\\x`.
|
|
||||||
/// * A root's subtree is the single range `[root + SEP, root + succ(SEP))`,
|
|
||||||
/// because the root's *own* parent is `root + SEP` rather than `root`. With
|
|
||||||
/// a bare `root` there is no such range: strings between `root` and
|
|
||||||
/// `root + SEP` are siblings (`/a-b` sorts inside `["/a", "/a0")`), so it
|
|
||||||
/// would take two predicates and a sibling would sneak in through either.
|
|
||||||
///
|
|
||||||
/// See [`crate::db::schema::SCHEMA_CURRENT`] for what that buys.
|
|
||||||
pub(crate) fn dir_to_db_parent(dir: &Path) -> String {
|
|
||||||
let mut s = path_to_db_string(dir);
|
|
||||||
// The platform's own separator only. On Unix `\` is an ordinary filename
|
|
||||||
// character, so a directory genuinely named `weird\` must still get its
|
|
||||||
// `/` — testing both separators would leave that row unjoinable.
|
|
||||||
if !s.ends_with(std::path::MAIN_SEPARATOR) {
|
|
||||||
s.push(std::path::MAIN_SEPARATOR);
|
|
||||||
}
|
|
||||||
s
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a stored path into the `(parent, name)` pair the index keys on.
|
|
||||||
///
|
|
||||||
/// The separator stays with the parent, so `parent` + `name` is the original
|
|
||||||
/// string back — see [`dir_to_db_parent`]. `None` for anything that cannot be
|
|
||||||
/// a file's path: a bare relative name with no separator at all, or a string
|
|
||||||
/// that ends in one and so names a directory rather than a file.
|
|
||||||
pub fn split_db_path(path: &str) -> Option<(&str, &str)> {
|
|
||||||
let cut = path.rfind(std::path::MAIN_SEPARATOR)?;
|
|
||||||
let (parent, name) = path.split_at(cut + 1);
|
|
||||||
(!name.is_empty()).then_some((parent, name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paths a walk could not read, collected as it runs.
|
/// Paths a walk could not read, collected as it runs.
|
||||||
|
|
@ -297,14 +204,6 @@ fn walk_entries<'a>(
|
||||||
) -> impl Iterator<Item = DirEntry> + 'a {
|
) -> impl Iterator<Item = DirEntry> + 'a {
|
||||||
WalkDir::new(root)
|
WalkDir::new(root)
|
||||||
.follow_links(follow_symlinks)
|
.follow_links(follow_symlinks)
|
||||||
// walkdir defaults this to *true*, independently of `follow_links`:
|
|
||||||
// without it a root that is itself a symlink gets descended even when
|
|
||||||
// following is off. That matters more than it sounds, because
|
|
||||||
// `prepare_file_record_from_path` canonicalizes before storing, so the
|
|
||||||
// rows land under the target's real path — and if the target is
|
|
||||||
// outside every configured root, no sweep range covers them and they
|
|
||||||
// are orphans until a rebuild.
|
|
||||||
.follow_root_links(follow_symlinks)
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_entry(move |e| walk_filter(e, follow_symlinks, include_hidden, ignore))
|
.filter_entry(move |e| walk_filter(e, follow_symlinks, include_hidden, ignore))
|
||||||
.filter_map(move |res| match res {
|
.filter_map(move |res| match res {
|
||||||
|
|
@ -321,18 +220,6 @@ fn walk_entries<'a>(
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// A symlink is an entry in its own right when following is off, and
|
|
||||||
// `walk_filter` cannot drop it: that runs before the descent and
|
|
||||||
// passes depth 0 unconditionally, because a root is the user's own
|
|
||||||
// choice. Yielding it would index the link as a file — and
|
|
||||||
// `prepare_file_record_from_path` canonicalizes, so the row would
|
|
||||||
// land under the target's real path. Inside a root that is a
|
|
||||||
// duplicate of a row the walk reaches anyway; outside every root it
|
|
||||||
// is a row no sweep range covers, and only a rebuild removes it.
|
|
||||||
//
|
|
||||||
// No cost when following is on: walkdir resolves links then, so
|
|
||||||
// nothing reports itself as one.
|
|
||||||
.filter(move |e| follow_symlinks || !e.file_type().is_symlink())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `root` yielding only files, pruning hidden and ignored subtrees
|
/// Walk `root` yielding only files, pruning hidden and ignored subtrees
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use std::time::UNIX_EPOCH;
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use super::paths::parent_str;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::repo::{self, NewFile};
|
use crate::db::repo::{self, NewFile};
|
||||||
|
|
@ -92,117 +93,14 @@ pub(super) fn get_file_hash(
|
||||||
Ok((hasher.finalize().to_vec(), head))
|
Ok((hasher.finalize().to_vec(), head))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// FTS5's automerge threshold, applied before a run writes anything.
|
/// Nudge FTS5 to merge its index segments. Best-effort; failure is logged
|
||||||
///
|
/// and swallowed.
|
||||||
/// The number of index segments that must accumulate at one level before FTS5
|
|
||||||
/// merges them: 2..=16, or 0 to disable incremental merging. Every merge
|
|
||||||
/// rewrites segments to disk, so this is a **write-amplification** knob rather
|
|
||||||
/// than a CPU one, and the FTS index is where essentially all of an indexing
|
|
||||||
/// run's writing goes — `searchabletext_data` measured 228 MiB of a 265 MiB
|
|
||||||
/// index, against 2.8 MiB for all six `files` indexes put together.
|
|
||||||
///
|
|
||||||
/// 16 is the maximum FTS5 accepts and is measured, on a 10,000-file tree, three
|
|
||||||
/// builds per setting:
|
|
||||||
///
|
|
||||||
/// | automerge | cold index | written | search (unlimited) |
|
|
||||||
/// |---:|---:|---:|---:|
|
|
||||||
/// | 4 (FTS5 default) | 8.53 / 8.65 / 8.58 s | 1862 / 1890 / 1818 MiB | 763–772 ms |
|
|
||||||
/// | 16 | 7.10 / 7.10 / 6.67 s | 1235 / 1221 / 1213 MiB | 756–769 ms |
|
|
||||||
///
|
|
||||||
/// **19% faster and a third fewer bytes written, for no search cost.** The
|
|
||||||
/// search column has to be read carefully, and is the reason this comment
|
|
||||||
/// exists: measured at the *default* display limit the same six indexes span
|
|
||||||
/// 40 ms to 540 ms, and none of that spread is automerge. `scan_pass` stops as
|
|
||||||
/// soon as the limit fills and streams FTS candidates in rowid order — which is
|
|
||||||
/// `file_id` order, which is whatever order the concurrent walk inserted rows
|
|
||||||
/// in — so an index where the large documents drew low ids decompresses
|
|
||||||
/// megabytes to fill 1000 hits where another reads a few hundred kilobytes.
|
|
||||||
/// Two builds of one configuration differed by 13x that way. Comparing
|
|
||||||
/// anything about FTS against a limited search measures that lottery instead;
|
|
||||||
/// raise the limit past the corpus so every index examines every candidate.
|
|
||||||
const WRITE_AUTOMERGE: u8 = 16;
|
|
||||||
|
|
||||||
/// Set FTS5's automerge threshold. Best-effort; failure is logged.
|
|
||||||
///
|
|
||||||
/// **This sets a parameter — it does not merge anything.** With a value bound
|
|
||||||
/// to `rank`, `INSERT INTO ft(ft, rank) VALUES('automerge', N)` writes N into
|
|
||||||
/// the table's `%_config`, where it persists. The merging command is
|
|
||||||
/// `'merge'`, and the merge-everything command is `'optimize'`; see
|
|
||||||
/// [`fts_finalize_after_text_indexing`].
|
|
||||||
///
|
|
||||||
/// That distinction was worth a great deal. This used to be called once, at the
|
|
||||||
/// *end* of a run, under the name `fts_finalize_after_text_indexing` and the
|
|
||||||
/// comment "nudge FTS5 to merge its index segments" — so a fresh index did its
|
|
||||||
/// entire first bulk load at FTS5's default threshold of 4, every later run
|
|
||||||
/// silently inherited 8 from the config table, and no merge was ever performed
|
|
||||||
/// at all.
|
|
||||||
pub fn fts_set_automerge(conn: &Connection, segments: u8) {
|
|
||||||
if let Err(e) = conn.execute(
|
|
||||||
"INSERT INTO searchabletext(searchabletext, rank) VALUES('automerge', ?1)",
|
|
||||||
[segments as i64],
|
|
||||||
) {
|
|
||||||
crate::log_warn!("FTS automerge failed (non-fatal): {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FTS5's crisis-merge threshold: the segment count at which it stops deferring
|
|
||||||
/// and *forces* a merge, whatever `automerge` would have preferred.
|
|
||||||
///
|
|
||||||
/// Raised from FTS5's default of 16 to 32 — half way to the 64 that measured
|
|
||||||
/// identically, so the safety valve this is stays nearer where SQLite put it.
|
|
||||||
/// Two builds each, 10,000-file tree, everything else equal:
|
|
||||||
///
|
|
||||||
/// | crisismerge | index | written | cold | search (unlimited) |
|
|
||||||
/// |---:|---:|---:|---:|---:|
|
|
||||||
/// | 16 (default) | 311.8 / 289.9 MiB | 1008 / 973 MiB | 5.54 / 5.51 s | 755–769 ms |
|
|
||||||
/// | 32 | **268.2 / 266.1 MiB** | 946 / 964 MiB | 5.53 / 5.52 s | 756–762 ms |
|
|
||||||
///
|
|
||||||
/// A **13% smaller index for no cost in time or search**, and — the part worth
|
|
||||||
/// noticing — a far more *stable* one: the default's size swings 290–312 MiB
|
|
||||||
/// between builds where this lands within 2 MiB of itself. Fewer forced merges
|
|
||||||
/// mid-load leave the final merge a tidier structure to consolidate.
|
|
||||||
const WRITE_CRISISMERGE: u8 = 32;
|
|
||||||
|
|
||||||
/// Apply the write-side FTS5 settings, before a run starts writing.
|
|
||||||
///
|
|
||||||
/// `pgsz` was swept here too and **rejected**: at 8192 and 16384 it wrote
|
|
||||||
/// 1061 MiB and 1008 MiB against the default's 943 MiB, for an index the same
|
|
||||||
/// size. It is a runtime option like these two — settable on an existing table,
|
|
||||||
/// not creation-time-only — so trying it cost nothing and needed no schema
|
|
||||||
/// change; it simply does not pay on this workload.
|
|
||||||
pub fn fts_begin_bulk_write(conn: &Connection) {
|
|
||||||
fts_set_automerge(conn, WRITE_AUTOMERGE);
|
|
||||||
if let Err(e) = conn.execute(
|
|
||||||
"INSERT INTO searchabletext(searchabletext, rank) VALUES('crisismerge', ?1)",
|
|
||||||
[WRITE_CRISISMERGE as i64],
|
|
||||||
) {
|
|
||||||
crate::log_warn!("FTS crisismerge failed (non-fatal): {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge FTS5 segments once a run has finished writing.
|
|
||||||
///
|
|
||||||
/// A real merge, which is what this function's name has always claimed and what
|
|
||||||
/// it never did. Cheap next to the load — measured at +0.2 s and +5 MiB on a
|
|
||||||
/// 20,000-file tree — and it is what reclaims the tombstones a
|
|
||||||
/// `contentless_delete` table accumulates, which is why the incremental callers
|
|
||||||
/// (`scope`, `cleanup_stale_index_entries`) want it after removing rows.
|
|
||||||
///
|
|
||||||
/// Deliberately **not** `'optimize'`. That merges everything into one segment
|
|
||||||
/// and costs, on the same tree, +1.8 s and +900 MiB written — for no measurable
|
|
||||||
/// search gain: it took the segment-index from 7,337 rows to 730 and left an
|
|
||||||
/// unlimited search within noise of where it started.
|
|
||||||
///
|
|
||||||
/// Best-effort; failure is logged and swallowed, because an unconsolidated
|
|
||||||
/// index is slower to search and still correct.
|
|
||||||
pub fn fts_finalize_after_text_indexing(conn: &Connection) {
|
pub fn fts_finalize_after_text_indexing(conn: &Connection) {
|
||||||
// A negative page budget means "keep merging until there is nothing left
|
|
||||||
// worth merging", rather than doing a fixed slice of the work.
|
|
||||||
if let Err(e) = conn.execute(
|
if let Err(e) = conn.execute(
|
||||||
"INSERT INTO searchabletext(searchabletext, rank) VALUES('merge', -16)",
|
"INSERT INTO searchabletext(searchabletext, rank) VALUES('automerge', 8)",
|
||||||
[],
|
[],
|
||||||
) {
|
) {
|
||||||
crate::log_warn!("FTS merge failed (non-fatal): {}", e);
|
crate::log_warn!("FTS automerge failed (non-fatal): {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -211,9 +109,7 @@ pub fn fts_finalize_after_text_indexing(conn: &Connection) {
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct OwnedNewFile {
|
pub struct OwnedNewFile {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// The containing directory, ending in the platform separator. With
|
pub path: String,
|
||||||
/// [`OwnedNewFile::name`] it is both the row's key and, concatenated, its
|
|
||||||
/// path — see [`super::paths::dir_to_db_parent`].
|
|
||||||
pub parent: String,
|
pub parent: String,
|
||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub mtime: u64,
|
pub mtime: u64,
|
||||||
|
|
@ -234,15 +130,10 @@ pub struct OwnedNewFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OwnedNewFile {
|
impl OwnedNewFile {
|
||||||
/// The file's path, rebuilt. Callers that only want it for a message
|
|
||||||
/// should say so — nothing stores this.
|
|
||||||
pub fn path(&self) -> String {
|
|
||||||
format!("{}{}", self.parent, self.name)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn as_new_file(&self) -> NewFile<'_> {
|
pub fn as_new_file(&self) -> NewFile<'_> {
|
||||||
NewFile {
|
NewFile {
|
||||||
name: &self.name,
|
name: &self.name,
|
||||||
|
path: &self.path,
|
||||||
parent: &self.parent,
|
parent: &self.parent,
|
||||||
size: self.size,
|
size: self.size,
|
||||||
mtime: self.mtime,
|
mtime: self.mtime,
|
||||||
|
|
@ -274,15 +165,12 @@ pub fn hash_failure_counts() -> (u64, u64) {
|
||||||
/// already holds. The single implementation behind both full-run batches
|
/// already holds. The single implementation behind both full-run batches
|
||||||
/// and incremental watcher updates.
|
/// and incremental watcher updates.
|
||||||
///
|
///
|
||||||
/// `path` must already be canonical and in stored spelling (see
|
/// `path` must already be canonical and in `files.path` spelling (see
|
||||||
/// [`path_to_db_string`]), and must still name the file once parsed back into
|
/// [`path_to_db_string`]), and must still name the file once parsed back into
|
||||||
/// a [`Path`] — this opens it by that string. A path that only survived
|
/// a [`Path`] — this opens it by that string. A path that only survived
|
||||||
/// `to_string_lossy` does not qualify: the lossy spelling of one name is the
|
/// `to_string_lossy` does not qualify; callers holding the original [`Path`]
|
||||||
/// real name of another, so it would hash and index the wrong file. The walk
|
/// screen it with [`warn_if_unrepresentable`] first. Callers holding an
|
||||||
/// screens for that on the directory entry, before the path is even built
|
/// unresolved path want [`prepare_file_record_from_path`] instead.
|
||||||
/// (`crate::walk::read_directory`); [`prepare_file_record_from_path`], which
|
|
||||||
/// is what callers holding an unresolved path want, screens with
|
|
||||||
/// [`warn_if_unrepresentable`].
|
|
||||||
///
|
///
|
||||||
/// Returns `None` for anything that isn't a readable regular file, with a
|
/// Returns `None` for anything that isn't a readable regular file, with a
|
||||||
/// warning when hashing fails.
|
/// warning when hashing fails.
|
||||||
|
|
@ -325,11 +213,10 @@ pub fn prepare_file_record(
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// One split for both halves of the key, and the separator stays with the
|
let name = Path::new(path)
|
||||||
// parent so the two concatenate back into `path`. `None` here means the
|
.file_name()
|
||||||
// caller handed us something that cannot be a file's path at all.
|
.map(|n| n.to_string_lossy().into_owned())?;
|
||||||
let (parent, name) = split_db_path(path)?;
|
let parent = parent_str(path);
|
||||||
let (parent, name) = (parent.to_string(), name.to_string());
|
|
||||||
// Sniff from the bytes hashing already read; an empty head falls back to
|
// Sniff from the bytes hashing already read; an empty head falls back to
|
||||||
// the extension.
|
// the extension.
|
||||||
let mime = guess_mime_from_head(Path::new(path), &head);
|
let mime = guess_mime_from_head(Path::new(path), &head);
|
||||||
|
|
@ -368,6 +255,7 @@ pub fn prepare_file_record(
|
||||||
|
|
||||||
Some(OwnedNewFile {
|
Some(OwnedNewFile {
|
||||||
name,
|
name,
|
||||||
|
path: path.to_string(),
|
||||||
parent,
|
parent,
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
|
||||||
|
|
@ -300,41 +300,6 @@ fn db_key_for_a_vanished_path_canonicalizes_what_remains() {
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The hazard every screen in the codebase exists for, stated once here.
|
|
||||||
///
|
|
||||||
/// `path_to_db_string` is many-to-one, and the collapsed spelling is not
|
|
||||||
/// garbage — it is a perfectly ordinary filename that a *different* file can
|
|
||||||
/// really have. So the stored key for an unrepresentable path is another
|
|
||||||
/// file's key, and any use of it (open, hash, delete by path, delete a subtree
|
|
||||||
/// range) lands on that file instead. `warn_if_unrepresentable` is how a
|
|
||||||
/// caller holding a single path avoids ever building one.
|
|
||||||
#[test]
|
|
||||||
fn the_stored_spelling_of_an_unrepresentable_path_is_another_files_key() {
|
|
||||||
let dir = Path::new("/docs");
|
|
||||||
let bad = dir.join(crate::testutil::unrepresentable_name("report", ".txt"));
|
|
||||||
let twin = dir.join(crate::testutil::lossy_twin("report", ".txt"));
|
|
||||||
|
|
||||||
assert!(warn_if_unrepresentable(&bad));
|
|
||||||
assert!(
|
|
||||||
!warn_if_unrepresentable(&twin),
|
|
||||||
"the twin is an ordinary name and must pass"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
path_to_db_string(&bad),
|
|
||||||
path_to_db_string(&twin),
|
|
||||||
"two different files, one stored key"
|
|
||||||
);
|
|
||||||
|
|
||||||
// And the same one component deeper, which is why a bad *directory* has to
|
|
||||||
// be pruned rather than walked: the collision is inherited by everything
|
|
||||||
// beneath it.
|
|
||||||
assert_eq!(
|
|
||||||
path_to_db_string(&bad.join("child.txt")),
|
|
||||||
path_to_db_string(&twin.join("child.txt"))
|
|
||||||
);
|
|
||||||
assert!(warn_if_unrepresentable(&bad.join("child.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn db_key_for_an_entirely_missing_path_falls_back_to_the_raw_spelling() {
|
fn db_key_for_an_entirely_missing_path_falls_back_to_the_raw_spelling() {
|
||||||
let nowhere = Path::new("relative-thing-that-does-not-exist.txt");
|
let nowhere = Path::new("relative-thing-that-does-not-exist.txt");
|
||||||
|
|
@ -431,212 +396,3 @@ fn hash_covers_size_and_head_only() {
|
||||||
|
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The invariant the whole schema rests on: `parent` and `name` concatenate
|
|
||||||
/// back into the path, with no separator logic at the join.
|
|
||||||
///
|
|
||||||
/// Checked against real paths built by `Path::join` rather than by string
|
|
||||||
/// formatting, so a platform whose separator is not `/` is tested too.
|
|
||||||
#[test]
|
|
||||||
fn a_split_path_concatenates_back_into_itself() {
|
|
||||||
let root = std::path::Path::new(if cfg!(windows) { r"C:\" } else { "/" });
|
|
||||||
let cases = [
|
|
||||||
root.join("a.txt"), // a file at the very root
|
|
||||||
root.join("home").join("me").join("x.md"), // the ordinary case
|
|
||||||
root.join("dir with spaces").join("y"),
|
|
||||||
root.join("weird.name").join("z.tar.gz"),
|
|
||||||
];
|
|
||||||
for path in cases {
|
|
||||||
let s = path_to_db_string(&path);
|
|
||||||
let (parent, name) = split_db_path(&s).expect("a file's path");
|
|
||||||
assert_eq!(
|
|
||||||
format!("{}{}", parent, name),
|
|
||||||
s,
|
|
||||||
"{:?} must round-trip through its (parent, name) key",
|
|
||||||
s
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
name,
|
|
||||||
path.file_name().unwrap().to_string_lossy(),
|
|
||||||
"the name half is the file name"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parent,
|
|
||||||
dir_to_db_parent(path.parent().unwrap()),
|
|
||||||
"the parent half is what `dir_to_db_parent` would store"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
parent.ends_with(MAIN_SEPARATOR),
|
|
||||||
"a stored parent always ends in a separator: {:?}",
|
|
||||||
parent
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!name.contains(MAIN_SEPARATOR),
|
|
||||||
"a stored name never contains one: {:?}",
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A filesystem root already ends in a separator, so `dir_to_db_parent` must
|
|
||||||
/// not add a second one — otherwise every file directly at the root would be
|
|
||||||
/// stored under `//` (or `C:\\`) and never found again.
|
|
||||||
#[test]
|
|
||||||
fn a_root_directory_does_not_get_a_doubled_separator() {
|
|
||||||
let root = std::path::Path::new(if cfg!(windows) { r"C:\" } else { "/" });
|
|
||||||
let parent = dir_to_db_parent(root);
|
|
||||||
assert_eq!(parent, path_to_db_string(root));
|
|
||||||
assert!(!parent.ends_with(&format!("{}{}", MAIN_SEPARATOR, MAIN_SEPARATOR)));
|
|
||||||
// And the join still produces a path that names the same file.
|
|
||||||
assert_eq!(
|
|
||||||
format!("{}{}", parent, "a.txt"),
|
|
||||||
path_to_db_string(&root.join("a.txt"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Strings that are not a file's path have no key, and must say so rather
|
|
||||||
/// than producing a half-formed one — every lookup helper reads `None` as
|
|
||||||
/// "not indexed".
|
|
||||||
#[test]
|
|
||||||
fn a_string_that_cannot_be_a_files_path_has_no_key() {
|
|
||||||
assert_eq!(split_db_path("bare-name.txt"), None, "no separator at all");
|
|
||||||
assert_eq!(
|
|
||||||
split_db_path(&format!("{}dir{}", MAIN_SEPARATOR, MAIN_SEPARATOR)),
|
|
||||||
None,
|
|
||||||
"trailing separator: names a directory, not a file"
|
|
||||||
);
|
|
||||||
assert_eq!(split_db_path(""), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On Unix a backslash is an ordinary filename character. Splitting on it
|
|
||||||
/// there would put half a name in the parent, and the row would be
|
|
||||||
/// unreachable by the path it was stored under.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_backslash_is_just_a_character_on_unix() {
|
|
||||||
let (parent, name) = split_db_path(r"/home/me/back\slash.txt").expect("a file's path");
|
|
||||||
assert_eq!(parent, "/home/me/");
|
|
||||||
assert_eq!(name, r"back\slash.txt");
|
|
||||||
|
|
||||||
// Same on the directory side: a folder genuinely named `weird\` still
|
|
||||||
// gets its own separator appended.
|
|
||||||
assert_eq!(
|
|
||||||
dir_to_db_parent(std::path::Path::new(r"/tmp/weird\")),
|
|
||||||
r"/tmp/weird\/"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// walkdir defaults `follow_root_links` to **true**, independently of
|
|
||||||
/// `follow_links` — so a root that is itself a symlink was descended even with
|
|
||||||
/// following off. It matters more than a duplicate scan: the records are
|
|
||||||
/// stored under the canonicalized path, so a target outside every configured
|
|
||||||
/// root lands where no sweep range reaches and stays there until a rebuild.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlinked_root_is_not_descended_when_following_is_off() {
|
|
||||||
let base = tmp_tree();
|
|
||||||
let real = base.join("real");
|
|
||||||
touch(&real.join("inside.txt"));
|
|
||||||
let link = base.join("link");
|
|
||||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
|
||||||
|
|
||||||
let ignore = IgnoreSet::compile(&[]).unwrap();
|
|
||||||
let names: Vec<String> = filtered_walk(
|
|
||||||
link.to_str().unwrap(),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&ignore,
|
|
||||||
&UnreadableDirs::default(),
|
|
||||||
)
|
|
||||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
names.is_empty(),
|
|
||||||
"a symlinked root must not be descended: {names:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// With following on it is descended, as it always was.
|
|
||||||
let names: Vec<String> = filtered_walk(
|
|
||||||
link.to_str().unwrap(),
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
&ignore,
|
|
||||||
&UnreadableDirs::default(),
|
|
||||||
)
|
|
||||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(names, vec!["inside.txt"]);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A symlink *inside* a root is the same hazard one level down: yielding it
|
|
||||||
/// as a file indexes it under the canonicalized target path.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlink_inside_a_root_is_skipped_when_following_is_off() {
|
|
||||||
let base = tmp_tree();
|
|
||||||
let root = base.join("root");
|
|
||||||
touch(&root.join("real.txt"));
|
|
||||||
let outside = base.join("outside");
|
|
||||||
touch(&outside.join("target.txt"));
|
|
||||||
std::os::unix::fs::symlink(outside.join("target.txt"), root.join("link.txt")).unwrap();
|
|
||||||
std::os::unix::fs::symlink(&outside, root.join("linkdir")).unwrap();
|
|
||||||
|
|
||||||
let ignore = IgnoreSet::compile(&[]).unwrap();
|
|
||||||
let mut names: Vec<String> = filtered_walk(
|
|
||||||
root.to_str().unwrap(),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&ignore,
|
|
||||||
&UnreadableDirs::default(),
|
|
||||||
)
|
|
||||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
||||||
.collect();
|
|
||||||
names.sort();
|
|
||||||
assert_eq!(names, vec!["real.txt"], "only the real file may be walked");
|
|
||||||
|
|
||||||
// Following on: the link resolves and its target is walked through it.
|
|
||||||
let mut names: Vec<String> = filtered_walk(
|
|
||||||
root.to_str().unwrap(),
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
&ignore,
|
|
||||||
&UnreadableDirs::default(),
|
|
||||||
)
|
|
||||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
|
||||||
.collect();
|
|
||||||
names.sort();
|
|
||||||
assert_eq!(names, vec!["link.txt", "real.txt", "target.txt"]);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same for the directory walk the watcher registers from: it must not
|
|
||||||
/// spend descriptors on a subtree the indexer will discard.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlinked_root_yields_no_directories_when_following_is_off() {
|
|
||||||
let base = tmp_tree();
|
|
||||||
let real = base.join("real");
|
|
||||||
touch(&real.join("sub/inside.txt"));
|
|
||||||
let link = base.join("link");
|
|
||||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
|
||||||
|
|
||||||
let ignore = IgnoreSet::compile(&[]).unwrap();
|
|
||||||
let dirs: Vec<String> = filtered_dirs(
|
|
||||||
link.to_str().unwrap(),
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
&ignore,
|
|
||||||
&UnreadableDirs::default(),
|
|
||||||
)
|
|
||||||
.map(|e| e.path().display().to_string())
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
dirs.is_empty(),
|
|
||||||
"a symlinked root must yield no directories: {dirs:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -102,20 +102,6 @@ fn upsert_path(
|
||||||
{
|
{
|
||||||
return Ok(Applied::Done);
|
return Ok(Applied::Done);
|
||||||
}
|
}
|
||||||
// Symlink-aware, and checked before the followed `metadata` below: with
|
|
||||||
// following off the walk will not descend a symlinked directory nor
|
|
||||||
// record a symlinked file, so neither may this. It is not merely
|
|
||||||
// redundant work — `prepare_file_record_from_path` canonicalizes, so a
|
|
||||||
// followed link whose target lies outside every root writes a row no
|
|
||||||
// sweep range covers, and nothing but a rebuild clears it.
|
|
||||||
if !config.indexing.follow_symlinks {
|
|
||||||
match std::fs::symlink_metadata(path) {
|
|
||||||
Ok(md) if md.file_type().is_symlink() => return Ok(Applied::Done),
|
|
||||||
// Already gone again — the pending Remove event handles it.
|
|
||||||
Err(_) => return Ok(Applied::Done),
|
|
||||||
Ok(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let Ok(meta) = std::fs::metadata(path) else {
|
let Ok(meta) = std::fs::metadata(path) else {
|
||||||
// Already gone again — the pending Remove event handles it.
|
// Already gone again — the pending Remove event handles it.
|
||||||
return Ok(Applied::Done);
|
return Ok(Applied::Done);
|
||||||
|
|
@ -124,17 +110,11 @@ fn upsert_path(
|
||||||
// A moved-in tree surfaces as one directory event; walk it with
|
// A moved-in tree surfaces as one directory event; walk it with
|
||||||
// the same filters as a full run.
|
// the same filters as a full run.
|
||||||
//
|
//
|
||||||
// A path the index cannot spell, alongside the ignore and hidden
|
// A non-UTF-8 path is a whole subtree missing from the index, so it
|
||||||
// short-circuits above: a genuine "nothing indexable here", not a
|
// is an error (the caller schedules a full run) rather than a quiet
|
||||||
// failure.
|
// `Ok`.
|
||||||
//
|
|
||||||
// It reads as a whole subtree missing from the index, which it is —
|
|
||||||
// but reporting that as an error would set `needs_full_run`
|
|
||||||
// (`coordinator::inner::apply_pending`), and a full run screens the
|
|
||||||
// same subtree out for the same reason. The reindex could not fix it,
|
|
||||||
// so every write inside such a directory would buy another one.
|
|
||||||
let Some(root) = path.to_str() else {
|
let Some(root) = path.to_str() else {
|
||||||
return Ok(Applied::Done);
|
return Err(format!("directory path is not valid UTF-8: {:?}", path));
|
||||||
};
|
};
|
||||||
// Streamed, not collected: `mv` of a large tree is one event, and
|
// Streamed, not collected: `mv` of a large tree is one event, and
|
||||||
// materialising its entries first is a `DirEntry` per file resident
|
// materialising its entries first is a `DirEntry` per file resident
|
||||||
|
|
@ -144,18 +124,10 @@ fn upsert_path(
|
||||||
//
|
//
|
||||||
// `skip` rather than re-testing every entry: `upsert_file` on an
|
// `skip` rather than re-testing every entry: `upsert_file` on an
|
||||||
// unchanged file is cheap but not free, and paying it again for
|
// unchanged file is cheap but not free, and paying it again for
|
||||||
// everything already done would cost a transaction and a hash per
|
// everything already done would make a large tree quadratic in the
|
||||||
// already-indexed file on every turn.
|
// number of turns it takes. The walk order is deterministic for an
|
||||||
//
|
// unchanged tree; if the tree does change under us the count is only
|
||||||
// Two honest limits on that. The walk itself is *not* skipped — the
|
// an optimisation, and the next full run is what makes it exact.
|
||||||
// iterator still reads every directory it passes over (and on Windows
|
|
||||||
// stats every entry), so the traversal cost stays quadratic in the
|
|
||||||
// number of turns even though the writes do not. And the count is a
|
|
||||||
// position, not an identity: if the tree changes under us the entries
|
|
||||||
// shift, so `skip(n)` skips the wrong files and those get no row until
|
|
||||||
// the next full run. Both are acceptable for a moved-in tree, which is
|
|
||||||
// finite and static in the usual case; if either ever matters, resume
|
|
||||||
// by last-path rather than by count.
|
|
||||||
let mut done = budget.resume_from;
|
let mut done = budget.resume_from;
|
||||||
for entry in filtered_walk(
|
for entry in filtered_walk(
|
||||||
root,
|
root,
|
||||||
|
|
@ -194,12 +166,12 @@ fn upsert_file(
|
||||||
|
|
||||||
let existing: Option<(i64, i64)> = tx
|
let existing: Option<(i64, i64)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, mtime FROM files WHERE parent = ?1 AND name = ?2",
|
"SELECT id, mtime FROM files WHERE path = ?1",
|
||||||
rusqlite::params![rec.parent, rec.name],
|
rusqlite::params![rec.path],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| format!("lookup {}: {}", rec.path(), e))?;
|
.map_err(|e| format!("lookup {}: {}", rec.path, e))?;
|
||||||
|
|
||||||
let file_id = match existing {
|
let file_id = match existing {
|
||||||
Some((_, mtime)) if mtime.max(0) as u64 == rec.mtime => return Ok(()),
|
Some((_, mtime)) if mtime.max(0) as u64 == rec.mtime => return Ok(()),
|
||||||
|
|
@ -228,7 +200,7 @@ fn upsert_file(
|
||||||
extract_and_store(
|
extract_and_store(
|
||||||
&tx,
|
&tx,
|
||||||
file_id,
|
file_id,
|
||||||
&rec.path(),
|
&rec.path,
|
||||||
rec.mime.as_deref(),
|
rec.mime.as_deref(),
|
||||||
registry,
|
registry,
|
||||||
config,
|
config,
|
||||||
|
|
@ -260,14 +232,6 @@ pub fn remove_paths(
|
||||||
.transaction()
|
.transaction()
|
||||||
.map_err(|e| format!("begin incremental tx: {}", e))?;
|
.map_err(|e| format!("begin incremental tx: {}", e))?;
|
||||||
for path in batch {
|
for path in batch {
|
||||||
// A path the index cannot spell was never indexed, so there is
|
|
||||||
// nothing here to delete — and `db_key_for_missing_path` is lossy,
|
|
||||||
// so going ahead would key the row of whichever *different* file
|
|
||||||
// owns the lossy spelling and delete it, plus its whole subtree
|
|
||||||
// range below.
|
|
||||||
if path.to_str().is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The insert side stores a canonicalized path, so the raw event
|
// The insert side stores a canonicalized path, so the raw event
|
||||||
// spelling is not a usable key — but the file is already gone, so
|
// spelling is not a usable key — but the file is already gone, so
|
||||||
// `canonicalize` cannot be called on it directly either.
|
// `canonicalize` cannot be called on it directly either.
|
||||||
|
|
@ -363,7 +327,7 @@ mod tests {
|
||||||
fn row(&self, path: &str) -> Option<(i64, i64, i64)> {
|
fn row(&self, path: &str) -> Option<(i64, i64, i64)> {
|
||||||
self.conn
|
self.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, mtime, content_state FROM files WHERE parent || name = ?1",
|
"SELECT id, mtime, content_state FROM files WHERE path = ?1",
|
||||||
rusqlite::params![path],
|
rusqlite::params![path],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
)
|
)
|
||||||
|
|
@ -407,48 +371,6 @@ mod tests {
|
||||||
|
|
||||||
/// The collapse must not change what ends up deleted — only how much work
|
/// The collapse must not change what ends up deleted — only how much work
|
||||||
/// it takes to get there.
|
/// it takes to get there.
|
||||||
/// A resume point must not outlive the event it describes.
|
|
||||||
///
|
|
||||||
/// The coordinator prunes `resume_from` alongside the queues; this is the
|
|
||||||
/// half of that contract the module itself can state — resuming from a
|
|
||||||
/// count that belonged to some earlier walk skips real files, and they get
|
|
||||||
/// no row until the next full run.
|
|
||||||
#[test]
|
|
||||||
fn resuming_past_the_end_indexes_nothing_rather_than_the_wrong_files() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
for i in 0..3 {
|
|
||||||
f.write(&format!("sub/f{i}.txt"), "body");
|
|
||||||
}
|
|
||||||
let sub = f.dir.join("sub");
|
|
||||||
|
|
||||||
// A count larger than the tree: every entry is skipped, and the result
|
|
||||||
// is an empty index rather than an arbitrary subset.
|
|
||||||
let outcome =
|
|
||||||
f.apply_resuming(&FsEvent::Create(sub.clone()), Duration::from_secs(3600), 99);
|
|
||||||
assert_eq!(outcome, Applied::Done);
|
|
||||||
assert_eq!(f.counts().0, 0);
|
|
||||||
|
|
||||||
// From zero — what a pruned resume point gives the next turn — the
|
|
||||||
// whole tree lands.
|
|
||||||
f.apply(&FsEvent::Create(sub));
|
|
||||||
assert_eq!(f.counts().0, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `batch_size = 0` must not panic the writer.
|
|
||||||
///
|
|
||||||
/// `chunks(0)` panics, and this one runs on the indexing thread above the
|
|
||||||
/// arm that would publish `IndexingStatus::Error` — so before the clamp a
|
|
||||||
/// hand-edited zero wedged indexing for the session while the UI went on
|
|
||||||
/// reading "Running". Every sibling call site already had `.max(1)`.
|
|
||||||
#[test]
|
|
||||||
fn a_zero_batch_size_does_not_panic_the_writer() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
f.config.processing.batch_size = 0;
|
|
||||||
f.write("a.txt", "body");
|
|
||||||
f.apply(&FsEvent::Create(f.dir.join("a.txt")));
|
|
||||||
assert_eq!(f.counts().0, 1, "the file should still be indexed");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A directory event is applied in slices, and a slice resumes where the
|
/// A directory event is applied in slices, and a slice resumes where the
|
||||||
/// last one stopped instead of re-walking what it already did.
|
/// last one stopped instead of re-walking what it already did.
|
||||||
///
|
///
|
||||||
|
|
@ -713,76 +635,6 @@ mod tests {
|
||||||
assert!(f.row(&survivor).is_some());
|
assert!(f.row(&survivor).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `Remove` for a path the index cannot spell must delete nothing.
|
|
||||||
///
|
|
||||||
/// `db_key_for_missing_path` ends in `path_to_db_string`, which is lossy,
|
|
||||||
/// and the key it returns is used twice: to delete a row by path, and as
|
|
||||||
/// the low end of a range that deletes everything beneath it. For an
|
|
||||||
/// unrepresentable path that key names a *different*, real file — so the
|
|
||||||
/// event would take that file's row and its whole subtree, for a file that
|
|
||||||
/// was never indexed in the first place.
|
|
||||||
#[test]
|
|
||||||
fn removing_an_unrepresentable_path_spares_its_lossy_twin() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
let twin = crate::testutil::lossy_twin("report", ".txt");
|
|
||||||
let kept = f.write(&twin, "the file that must survive");
|
|
||||||
f.apply(&FsEvent::Create(kept.clone()));
|
|
||||||
let canonical = f.canonical(&kept);
|
|
||||||
assert!(f.row(&canonical).is_some(), "seeded");
|
|
||||||
|
|
||||||
// Never written to disk: the event alone is enough, and a Remove is
|
|
||||||
// for a path that is already gone in any case.
|
|
||||||
let bad = f
|
|
||||||
.dir
|
|
||||||
.join(crate::testutil::unrepresentable_name("report", ".txt"));
|
|
||||||
f.apply(&FsEvent::Remove(bad));
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
f.row(&canonical).is_some(),
|
|
||||||
"the real file's row was deleted by an event for a different file"
|
|
||||||
);
|
|
||||||
assert_eq!(f.counts().0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A directory event for a path the index cannot spell is a quiet `Ok`,
|
|
||||||
/// not an error.
|
|
||||||
///
|
|
||||||
/// `coordinator::inner::apply_pending` turns any `Err` here into
|
|
||||||
/// `needs_full_run`. A full run screens the same subtree out for the same
|
|
||||||
/// reason, so the reindex could not fix anything — it would just run
|
|
||||||
/// again on the next write into that directory, forever. Live on Windows,
|
|
||||||
/// where a `\\wsl.localhost\` or Samba tree can hold such a directory and
|
|
||||||
/// the root watch is recursive.
|
|
||||||
#[test]
|
|
||||||
fn a_directory_event_for_an_unrepresentable_path_is_not_an_error() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
let bad = f.dir.join(crate::testutil::unrepresentable_name("dir", ""));
|
|
||||||
if std::fs::create_dir_all(&bad).is_err() {
|
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::fs::write(bad.join("inside.txt"), "body").unwrap();
|
|
||||||
|
|
||||||
let outcome = apply_fs_event(
|
|
||||||
&mut f.conn,
|
|
||||||
&FsEvent::Create(bad),
|
|
||||||
&f.config,
|
|
||||||
&f.ignore,
|
|
||||||
&f.registry,
|
|
||||||
&Budget {
|
|
||||||
deadline: Instant::now() + Duration::from_secs(3600),
|
|
||||||
cancel: &AtomicBool::new(false),
|
|
||||||
resume_from: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
outcome,
|
|
||||||
Ok(Applied::Done),
|
|
||||||
"an unindexable subtree is not a failure"
|
|
||||||
);
|
|
||||||
assert_eq!(f.counts().0, 0, "and nothing under it is indexed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn oversize_files_get_content_na() {
|
fn oversize_files_get_content_na() {
|
||||||
let mut f = Fixture::new();
|
let mut f = Fixture::new();
|
||||||
|
|
@ -793,73 +645,4 @@ mod tests {
|
||||||
let (_, _, content_state) = f.row(&canonical).unwrap();
|
let (_, _, content_state) = f.row(&canonical).unwrap();
|
||||||
assert_eq!(content_state, repo::STATE_NA);
|
assert_eq!(content_state, repo::STATE_NA);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `follow_symlinks = false` means the walk will not descend a symlinked
|
|
||||||
/// directory, and the live path must agree. It used to disagree twice
|
|
||||||
/// over: `metadata` follows, so the link read as a directory, and
|
|
||||||
/// `prepare_file_record_from_path` canonicalizes before storing — so a
|
|
||||||
/// target outside every root produced rows under the target's real path
|
|
||||||
/// that no sweep range covers. Nothing but a rebuild cleared them.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlinked_directory_is_not_followed_when_following_is_off() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
assert!(
|
|
||||||
!f.config.indexing.follow_symlinks,
|
|
||||||
"the default this test is about"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A tree outside every configured root, and a link to it inside one.
|
|
||||||
let outside = crate::testutil::scratch_dir("incr-outside");
|
|
||||||
std::fs::write(outside.join("secret.txt"), "content out of scope").unwrap();
|
|
||||||
let link = f.dir.join("link");
|
|
||||||
std::os::unix::fs::symlink(&outside, &link).unwrap();
|
|
||||||
|
|
||||||
f.apply(&FsEvent::Create(link.clone()));
|
|
||||||
assert_eq!(
|
|
||||||
f.counts(),
|
|
||||||
(0, 0, 0),
|
|
||||||
"nothing under the link may be indexed"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&outside).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same for a symlinked *file*: canonicalization would file it under
|
|
||||||
/// the target's path, which is outside the root that produced the event.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlinked_file_is_not_indexed_when_following_is_off() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
let outside = crate::testutil::scratch_dir("incr-outside-file");
|
|
||||||
let target = outside.join("target.txt");
|
|
||||||
std::fs::write(&target, "content out of scope").unwrap();
|
|
||||||
let link = f.dir.join("link.txt");
|
|
||||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
|
||||||
|
|
||||||
f.apply(&FsEvent::Create(link.clone()));
|
|
||||||
assert_eq!(f.counts(), (0, 0, 0), "the link must not be indexed");
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&outside).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With following on, the link is indexed — under the target's real path,
|
|
||||||
/// which is what canonicalization has always done. This is the other half
|
|
||||||
/// of the guard: it must gate on the setting, not refuse symlinks outright.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlinked_file_is_indexed_when_following_is_on() {
|
|
||||||
let mut f = Fixture::new();
|
|
||||||
f.config.indexing.follow_symlinks = true;
|
|
||||||
let target = f.write("target.txt", "greetings earthling");
|
|
||||||
let link = f.dir.join("link.txt");
|
|
||||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
|
||||||
|
|
||||||
f.apply(&FsEvent::Create(link.clone()));
|
|
||||||
let canonical = f.canonical(&target);
|
|
||||||
assert!(
|
|
||||||
f.row(&canonical).is_some(),
|
|
||||||
"the link should have indexed its target"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,20 +204,6 @@ impl IndexingService {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn stop_indexing(&self) -> Result<(), String> {
|
pub fn stop_indexing(&self) -> Result<(), String> {
|
||||||
self.stop_indexing_inner(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Self::stop_indexing`] for a caller that is about to delete the file.
|
|
||||||
///
|
|
||||||
/// Skips the checkpoint. `checkpoint_truncate` copies the whole write-ahead
|
|
||||||
/// log into the database — up to `maximum_wal_size`, 512 MiB by default —
|
|
||||||
/// and the next two statements delete both. It is pure cost, and it is
|
|
||||||
/// paid on the coordinator's thread while the user waits for a rebuild.
|
|
||||||
fn stop_indexing_for_delete(&self) -> Result<(), String> {
|
|
||||||
self.stop_indexing_inner(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stop_indexing_inner(&self, checkpoint: bool) -> Result<(), String> {
|
|
||||||
self.command_tx
|
self.command_tx
|
||||||
.send(IndexingCommand::Stop)
|
.send(IndexingCommand::Stop)
|
||||||
.map_err(|e| format!("Failed to send stop command: {}", e))?;
|
.map_err(|e| format!("Failed to send stop command: {}", e))?;
|
||||||
|
|
@ -240,27 +226,15 @@ impl IndexingService {
|
||||||
// Flush the WAL and release the shared connection. WAL mode itself
|
// Flush the WAL and release the shared connection. WAL mode itself
|
||||||
// stays on — it's the persistent journal mode for the index.
|
// stays on — it's the persistent journal mode for the index.
|
||||||
if let Some(db_conn_arc) = crate::lock_ok(&self.db_connection).take() {
|
if let Some(db_conn_arc) = crate::lock_ok(&self.db_connection).take() {
|
||||||
if checkpoint {
|
let conn = crate::lock_ok(&db_conn_arc);
|
||||||
let conn = crate::lock_ok(&db_conn_arc);
|
if let Err(e) = crate::db::repo::checkpoint_truncate(&conn) {
|
||||||
if let Err(e) = crate::db::repo::checkpoint_truncate(&conn) {
|
crate::log_warn!("{}", e);
|
||||||
crate::log_warn!("{}", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a failure that happened *outside* a run, so the status bar
|
|
||||||
/// shows it rather than only the log ring.
|
|
||||||
///
|
|
||||||
/// A failed "Rebuild index" is the case this exists for: the user asked
|
|
||||||
/// for something, it did not happen, and a warning in a tab they are not
|
|
||||||
/// looking at is not telling them.
|
|
||||||
pub fn report_error(&self, message: String) {
|
|
||||||
*crate::lock_ok(&self.status) = IndexingStatus::Error(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_status(&self) -> IndexingStatus {
|
pub fn get_status(&self) -> IndexingStatus {
|
||||||
crate::lock_ok(&self.status).clone()
|
crate::lock_ok(&self.status).clone()
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +279,7 @@ impl IndexingService {
|
||||||
|
|
||||||
/// Stop indexing and delete the database file for a clean rebuild
|
/// Stop indexing and delete the database file for a clean rebuild
|
||||||
pub fn delete_index_for_rebuild(&self, db_path: &str) -> Result<(), String> {
|
pub fn delete_index_for_rebuild(&self, db_path: &str) -> Result<(), String> {
|
||||||
self.stop_indexing_for_delete()
|
self.stop_indexing()
|
||||||
.map_err(|e| format!("Failed to stop indexing: {}", e))?;
|
.map_err(|e| format!("Failed to stop indexing: {}", e))?;
|
||||||
// Cut short the optimize pass too: the file about to be deleted is
|
// Cut short the optimize pass too: the file about to be deleted is
|
||||||
// the file it holds open.
|
// the file it holds open.
|
||||||
|
|
@ -334,15 +308,8 @@ impl IndexingService {
|
||||||
// [`db::bump_index_epoch`].
|
// [`db::bump_index_epoch`].
|
||||||
db::bump_index_epoch();
|
db::bump_index_epoch();
|
||||||
|
|
||||||
let path = std::path::Path::new(db_path);
|
if std::path::Path::new(db_path).exists() {
|
||||||
if path.exists() {
|
std::fs::remove_file(db_path)
|
||||||
// Retried, as every other delete of this file is: on Windows it
|
|
||||||
// fails while any handle is open, and the readers are exactly the
|
|
||||||
// ones a user has just been using — the search worker holds its
|
|
||||||
// connection for `IDLE_RELEASE` after the last keystroke. The
|
|
||||||
// caller releases that one first; this covers a scan or a
|
|
||||||
// duplicates pass that has not noticed yet.
|
|
||||||
crate::platform::remove_file_retrying(path)
|
|
||||||
.map_err(|e| format!("Failed to delete database file: {}", e))?;
|
.map_err(|e| format!("Failed to delete database file: {}", e))?;
|
||||||
}
|
}
|
||||||
for suffix in ["-wal", "-shm", "-journal"] {
|
for suffix in ["-wal", "-shm", "-journal"] {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
@ -14,8 +13,7 @@ use crate::db;
|
||||||
use crate::db::repo;
|
use crate::db::repo;
|
||||||
use crate::extract::Registry;
|
use crate::extract::Registry;
|
||||||
use crate::file_handling::{
|
use crate::file_handling::{
|
||||||
cleanup_stale_index_entries, count_tree_entries_fast, fts_begin_bulk_write,
|
cleanup_stale_index_entries, count_tree_entries_fast, fts_finalize_after_text_indexing,
|
||||||
fts_finalize_after_text_indexing,
|
|
||||||
mark_oversize_pending_na, normalize_root_string, process_batch_inserts, process_batch_updates,
|
mark_oversize_pending_na, normalize_root_string, process_batch_inserts, process_batch_updates,
|
||||||
store_extracted, ExtractCursor, ExtractScope, FileIndexAction, OwnedNewFile,
|
store_extracted, ExtractCursor, ExtractScope, FileIndexAction, OwnedNewFile,
|
||||||
};
|
};
|
||||||
|
|
@ -76,81 +74,6 @@ fn wal_len(path: &str) -> u64 {
|
||||||
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
|
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Free space at which a run gives up rather than keep writing.
|
|
||||||
///
|
|
||||||
/// Filling the volume the index sits on is not a clean failure. SQLite's
|
|
||||||
/// guard against a short wal-index only covers the moment that file is
|
|
||||||
/// *extended*; a later write goes through the `-shm` mmap, and a page fault
|
|
||||||
/// the filesystem cannot back is delivered as **SIGBUS**, which no `Result`
|
|
||||||
/// can catch. On a copy-on-write filesystem (btrfs, ZFS) even overwriting an
|
|
||||||
/// already-allocated page needs a new extent, so a full volume can take the
|
|
||||||
/// process down on a write to a page that has existed for hours. Stopping
|
|
||||||
/// with an error while there is still room is the only safe end.
|
|
||||||
const DISK_FLOOR: u64 = 128 * 1024 * 1024;
|
|
||||||
|
|
||||||
/// Share of the free space above [`DISK_FLOOR`] the log may occupy.
|
|
||||||
///
|
|
||||||
/// The log is what grows unboundedly between checkpoints, so its cap is the
|
|
||||||
/// figure that has to fit in what is left. A quarter leaves room for the
|
|
||||||
/// index's own growth, the FTS segments a merge writes beside it, and
|
|
||||||
/// whatever else on the machine wants the same volume.
|
|
||||||
const WAL_SHARE_OF_FREE: u64 = 4;
|
|
||||||
|
|
||||||
/// The configured checkpoint threshold, lowered to what the volume can
|
|
||||||
/// actually absorb.
|
|
||||||
///
|
|
||||||
/// `maximum_wal_size` is a stall-frequency knob chosen against a roomy disk;
|
|
||||||
/// on a nearly full one its 512 MiB default is more than everything left.
|
|
||||||
/// Checkpointing sooner costs some lock acquisitions and keeps the log inside
|
|
||||||
/// the space available — see [`DISK_FLOOR`] for why running out is not
|
|
||||||
/// survivable.
|
|
||||||
///
|
|
||||||
/// A configured `0` (forced checkpoints off) is bounded like any other value
|
|
||||||
/// rather than special-cased: the knob turns off a *performance* behaviour and
|
|
||||||
/// is not a licence to fill the disk. On a roomy volume the derived bound is
|
|
||||||
/// larger than any run's log, so `0` keeps its meaning without a second rule.
|
|
||||||
/// Unknown free space changes nothing.
|
|
||||||
fn wal_cap_for_volume(configured: u64, db_path: &Path) -> u64 {
|
|
||||||
let Some(free) = crate::platform::available_space(db_path) else {
|
|
||||||
return configured;
|
|
||||||
};
|
|
||||||
let effective = wal_cap_for_free(configured, free);
|
|
||||||
if effective != configured {
|
|
||||||
crate::log_info!(
|
|
||||||
"{} free where the index lives: forcing a WAL checkpoint every {} MiB \
|
|
||||||
instead of {}",
|
|
||||||
human_mib(free),
|
|
||||||
effective / (1024 * 1024),
|
|
||||||
match configured {
|
|
||||||
0 => "never".to_string(),
|
|
||||||
n => format!("{} MiB", n / (1024 * 1024)),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
effective
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The arithmetic of [`wal_cap_for_volume`], split from the syscall so it is
|
|
||||||
/// testable without a filesystem of a chosen size — the same split the rest of
|
|
||||||
/// the codebase makes for anything decidable without asking the kernel.
|
|
||||||
pub(super) fn wal_cap_for_free(configured: u64, free: u64) -> u64 {
|
|
||||||
let room = free.saturating_sub(DISK_FLOOR) / WAL_SHARE_OF_FREE;
|
|
||||||
// Never below the floor a configured value would be raised to: checkpoints
|
|
||||||
// more often than that cost more in lock acquisition than the log costs in
|
|
||||||
// space, and the in-run check is what actually stops a doomed run.
|
|
||||||
let capped = room.max(crate::config::MINIMUM_WAL_SIZE);
|
|
||||||
// `0` is "no cap", so it loses every `min` — hence the explicit arm.
|
|
||||||
if configured == 0 {
|
|
||||||
capped
|
|
||||||
} else {
|
|
||||||
configured.min(capped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn human_mib(bytes: u64) -> String {
|
|
||||||
format!("{} MiB", bytes / (1024 * 1024))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Flips an [`AtomicBool`] when dropped. Held by `run_indexing` so the
|
/// Flips an [`AtomicBool`] when dropped. Held by `run_indexing` so the
|
||||||
/// per-root count subprocesses die on every exit path of a run.
|
/// per-root count subprocesses die on every exit path of a run.
|
||||||
struct CancelOnDrop(Arc<AtomicBool>);
|
struct CancelOnDrop(Arc<AtomicBool>);
|
||||||
|
|
@ -281,17 +204,6 @@ impl RootPipeline {
|
||||||
},
|
},
|
||||||
// Earlier runs' rows count once the pass has counted them; until
|
// Earlier runs' rows count once the pass has counted them; until
|
||||||
// then only this run's, so the figure never goes backwards.
|
// then only this run's, so the figure never goes backwards.
|
||||||
//
|
|
||||||
// Not clamped against `extract_total`, deliberately. The count now
|
|
||||||
// runs *behind* the pass's first page rather than blocking its
|
|
||||||
// workers in front of it (see `content::feeder`), so a row written
|
|
||||||
// while it was in flight is seen by it as already done and counted
|
|
||||||
// again in `written` — the numerator can briefly overshoot. That is
|
|
||||||
// already a shape this reports: a pass fed rows from outside its own
|
|
||||||
// range writes them with an `extract_total` of zero, which
|
|
||||||
// `an_extracting_turn_lands_its_leftovers_one_slice_at_a_time`
|
|
||||||
// pins. Clamping here broke that test and would have hidden the
|
|
||||||
// case it exists to describe.
|
|
||||||
extracted: totals.map_or(self.written, |t| t.already_done + self.written),
|
extracted: totals.map_or(self.written, |t| t.already_done + self.written),
|
||||||
extract_total: totals.map(|t| t.pending + t.already_done),
|
extract_total: totals.map(|t| t.pending + t.already_done),
|
||||||
current_file: self.current_file.clone(),
|
current_file: self.current_file.clone(),
|
||||||
|
|
@ -822,45 +734,6 @@ impl IndexingService {
|
||||||
}
|
}
|
||||||
Self::update_config(&conn, config, &roots)?;
|
Self::update_config(&conn, config, &roots)?;
|
||||||
|
|
||||||
// Before a single row is written, so the whole load runs at the
|
|
||||||
// write-side threshold. Setting it afterwards — which is what this
|
|
||||||
// used to do — left every fresh index's first run at FTS5's default.
|
|
||||||
fts_begin_bulk_write(&conn);
|
|
||||||
|
|
||||||
// Turn off SQLite's automatic checkpointing for the duration of the
|
|
||||||
// run, because during a run it cannot do its job and charges full
|
|
||||||
// price for failing.
|
|
||||||
//
|
|
||||||
// The default fires every 1000 pages (~4 MB) and copies the log back
|
|
||||||
// into the database — but it can only *reset* the log at an instant no
|
|
||||||
// reader holds a read mark, and this run keeps a reader per root from
|
|
||||||
// start to finish (the walk's row prefetcher, then the content pass's
|
|
||||||
// feeder). So it copied pages back perpetually and never truncated
|
|
||||||
// anything: measured on a 10,000-file tree, the log grew to 144 MiB and
|
|
||||||
// stayed there while the process wrote **1,220 MiB** — the same log
|
|
||||||
// copied back some seven times over.
|
|
||||||
//
|
|
||||||
// Safe here and nowhere else, which is why it is set on this connection
|
|
||||||
// rather than in `PRAGMAS_FAST`: this is the one writer that already
|
|
||||||
// owns the machinery to land its own log. `wal_cap_for_volume` bounds
|
|
||||||
// how large it may grow — by free space, not just by
|
|
||||||
// `maximum_wal_size` — the loop below forces a checkpoint at that cap,
|
|
||||||
// and the optimize pass checkpoints again at the end. A writer without
|
|
||||||
// all three (`cli::clear_path`, say) must keep the automatic one.
|
|
||||||
//
|
|
||||||
// Measured, two runs each, same tree:
|
|
||||||
//
|
|
||||||
// | | cold | written | log peak |
|
|
||||||
// |---|---:|---:|---:|
|
|
||||||
// | autocheckpoint on (default) | 7.19 / 7.37 s | 1219 / 1227 MiB | 144 MiB |
|
|
||||||
// | off | **5.59 / 5.50 s** | **990 / 992 MiB** | 512 MiB |
|
|
||||||
//
|
|
||||||
// The log gets larger and the run gets cheaper, which is the trade the
|
|
||||||
// default is making backwards for this workload.
|
|
||||||
if let Err(e) = conn.execute_batch("PRAGMA wal_autocheckpoint = 0;") {
|
|
||||||
crate::log_warn!("could not disable autocheckpoint (non-fatal): {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No up-front load of the whole `files` table: each walk's prefetcher
|
// No up-front load of the whole `files` table: each walk's prefetcher
|
||||||
// fetches one directory's rows at a time.
|
// fetches one directory's rows at a time.
|
||||||
let conn_mutex = Arc::new(Mutex::new(conn));
|
let conn_mutex = Arc::new(Mutex::new(conn));
|
||||||
|
|
@ -907,11 +780,10 @@ impl IndexingService {
|
||||||
// Log size at which to force a checkpoint; see [`wal_len`] for why
|
// Log size at which to force a checkpoint; see [`wal_len`] for why
|
||||||
// SQLite's autocheckpoint cannot be left to do this.
|
// SQLite's autocheckpoint cannot be left to do this.
|
||||||
let wal_path = format!("{}-wal", db_path);
|
let wal_path = format!("{}-wal", db_path);
|
||||||
let configured_cap = match config.processing.maximum_wal_size {
|
let wal_cap = match config.processing.maximum_wal_size {
|
||||||
0 => 0,
|
0 => 0,
|
||||||
n => n.max(crate::config::MINIMUM_WAL_SIZE),
|
n => n.max(crate::config::MINIMUM_WAL_SIZE),
|
||||||
};
|
};
|
||||||
let wal_cap = wal_cap_for_volume(configured_cap, Path::new(db_path));
|
|
||||||
let mut checkpoint_at = wal_cap;
|
let mut checkpoint_at = wal_cap;
|
||||||
|
|
||||||
// Walks first, one slice each, then a single extraction slice.
|
// Walks first, one slice each, then a single extraction slice.
|
||||||
|
|
@ -980,24 +852,6 @@ impl IndexingService {
|
||||||
crate::log_warn!("{}", e);
|
crate::log_warn!("{}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Only here, not every round: this is the moment the log is
|
|
||||||
// at its largest, and it costs one syscall per checkpoint
|
|
||||||
// rather than one per writer turn. The checkpoint above has
|
|
||||||
// just returned whatever it could, so what is left is the
|
|
||||||
// honest figure.
|
|
||||||
if let Some(free) = crate::platform::available_space(Path::new(db_path)) {
|
|
||||||
if free < DISK_FLOOR {
|
|
||||||
return Err(format!(
|
|
||||||
"Stopped: only {} free where the index lives ({}). \
|
|
||||||
Indexing needs room for its write-ahead log, and \
|
|
||||||
filling the disk can kill the process outright \
|
|
||||||
rather than fail cleanly. Free some space and run \
|
|
||||||
again — what is already indexed is kept.",
|
|
||||||
human_mib(free),
|
|
||||||
db_path
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Re-armed from what is on disk: a checkpoint that lost the
|
// Re-armed from what is on disk: a checkpoint that lost the
|
||||||
// race then costs one attempt per further `wal_cap` of
|
// race then costs one attempt per further `wal_cap` of
|
||||||
// growth, not a retry every round.
|
// growth, not a retry every round.
|
||||||
|
|
@ -1025,17 +879,6 @@ impl IndexingService {
|
||||||
.is_some();
|
.is_some();
|
||||||
// Nothing walking (extracting passes have no such handle);
|
// Nothing walking (extracting passes have no such handle);
|
||||||
// fall back to the sleep.
|
// fall back to the sleep.
|
||||||
//
|
|
||||||
// Parking on an extracting root's channel here was tried and
|
|
||||||
// **measured at nothing**: over a cold run of a 10,000-file
|
|
||||||
// tree the loop found nothing 91 times and only *one* of those
|
|
||||||
// reached this sleep, because the writer is the bottleneck
|
|
||||||
// during extraction and is almost never idle. One 2 ms sleep a
|
|
||||||
// run — 15.6 ms on Windows, where the timer granularity is what
|
|
||||||
// makes this comment worth having — did not justify a second
|
|
||||||
// `wait_ready` and the `pending` slot it needs. Re-measure with
|
|
||||||
// an extraction-bound corpus (PDFs, a network share) before
|
|
||||||
// concluding otherwise.
|
|
||||||
if !waited {
|
if !waited {
|
||||||
thread::sleep(IDLE_BACKOFF);
|
thread::sleep(IDLE_BACKOFF);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -166,11 +166,8 @@ fn an_extracting_turn_lands_its_leftovers_one_slice_at_a_time() {
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: &format!("f{}.txt", i),
|
name: &format!("f{}.txt", i),
|
||||||
// `dir_to_db_parent`, not `to_string_lossy`: a stored
|
path: &path.to_string_lossy(),
|
||||||
// parent always ends in a separator, and a row spelled
|
parent: &tree.to_string_lossy(),
|
||||||
// without one sorts below every `ExtractCursor` range that
|
|
||||||
// should contain it.
|
|
||||||
parent: &crate::file_handling::dir_to_db_parent(&tree),
|
|
||||||
size: 22,
|
size: 22,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -673,62 +670,3 @@ fn a_run_with_no_roots_is_complete_rather_than_unknown() {
|
||||||
assert_eq!(o.processed, 0);
|
assert_eq!(o.processed, 0);
|
||||||
assert_eq!(o.total, Some(0));
|
assert_eq!(o.total, Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A roomy volume leaves `maximum_wal_size` exactly as configured: this is a
|
|
||||||
/// safety valve, not a second tuning knob.
|
|
||||||
#[test]
|
|
||||||
fn a_roomy_volume_does_not_move_the_checkpoint_threshold() {
|
|
||||||
use super::pipeline::wal_cap_for_free;
|
|
||||||
let configured = 512 * 1024 * 1024;
|
|
||||||
assert_eq!(
|
|
||||||
wal_cap_for_free(configured, 500 * 1024 * 1024 * 1024),
|
|
||||||
configured
|
|
||||||
);
|
|
||||||
// Exactly enough: floor plus four times the log.
|
|
||||||
let just_enough = 128 * 1024 * 1024 + configured * 4;
|
|
||||||
assert_eq!(wal_cap_for_free(configured, just_enough), configured);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A tight volume checkpoints sooner, so the log cannot grow into the space
|
|
||||||
/// that is left. Running out is not a clean failure — the wal-index is reached
|
|
||||||
/// through an mmap, and a write the filesystem cannot back is a SIGBUS.
|
|
||||||
#[test]
|
|
||||||
fn a_tight_volume_lowers_the_checkpoint_threshold() {
|
|
||||||
use super::pipeline::wal_cap_for_free;
|
|
||||||
let configured = 512 * 1024 * 1024;
|
|
||||||
// 1 GiB free: 896 MiB above the floor, a quarter of which is 224 MiB.
|
|
||||||
let got = wal_cap_for_free(configured, 1024 * 1024 * 1024);
|
|
||||||
assert_eq!(got, 224 * 1024 * 1024);
|
|
||||||
assert!(got < configured);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `0` means "never force a checkpoint", which is a performance choice and not
|
|
||||||
/// a licence to fill the disk — so it is bounded by the volume like any other
|
|
||||||
/// value. On a roomy disk that bound is larger than any run's log, which is
|
|
||||||
/// how the setting keeps its meaning without a second rule to special-case it.
|
|
||||||
#[test]
|
|
||||||
fn disabled_checkpoints_are_still_bounded_by_the_volume() {
|
|
||||||
use super::pipeline::wal_cap_for_free;
|
|
||||||
let roomy = wal_cap_for_free(0, 500 * 1024 * 1024 * 1024);
|
|
||||||
assert!(
|
|
||||||
roomy > 100 * 1024 * 1024 * 1024,
|
|
||||||
"effectively never on a roomy disk, got {} bytes",
|
|
||||||
roomy
|
|
||||||
);
|
|
||||||
let tight = wal_cap_for_free(0, 1024 * 1024 * 1024);
|
|
||||||
assert_eq!(tight, 224 * 1024 * 1024, "a tight one checkpoints anyway");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Below the floor there is nothing sensible left to divide, and the run is
|
|
||||||
/// about to be stopped by the in-run check anyway — so the threshold bottoms
|
|
||||||
/// out at the same floor a configured value is raised to, never at zero.
|
|
||||||
#[test]
|
|
||||||
fn a_full_volume_bottoms_out_at_the_minimum_rather_than_zero() {
|
|
||||||
use super::pipeline::wal_cap_for_free;
|
|
||||||
for free in [0, 1024, 127 * 1024 * 1024] {
|
|
||||||
assert_eq!(
|
|
||||||
wal_cap_for_free(512 * 1024 * 1024, free),
|
|
||||||
crate::config::MINIMUM_WAL_SIZE
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -329,18 +329,7 @@ impl Loop {
|
||||||
&mut self.pending,
|
&mut self.pending,
|
||||||
&mut self.orphan_to,
|
&mut self.orphan_to,
|
||||||
);
|
);
|
||||||
// Either queue, not just `pending`. `orphan_to` collects
|
if !self.pending.is_empty() && self.settle_at.is_none() {
|
||||||
// rename destinations whose source is not on screen, and
|
|
||||||
// it is drained only by `flush_settled` — so arming on
|
|
||||||
// `pending` alone means churn that touches no visible row
|
|
||||||
// pushes for the life of the arm and is never taken. That
|
|
||||||
// is a leak, and on Windows it is also a wrong answer:
|
|
||||||
// with no `RenameMode::Both` there, the pairing below
|
|
||||||
// needs `orphans.len() == 1`, so one stale entry turns
|
|
||||||
// every later rename into "gone".
|
|
||||||
if (!self.pending.is_empty() || !self.orphan_to.is_empty())
|
|
||||||
&& self.settle_at.is_none()
|
|
||||||
{
|
|
||||||
self.settle_at = Some(Instant::now() + SETTLE);
|
self.settle_at = Some(Instant::now() + SETTLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -379,10 +368,9 @@ impl Loop {
|
||||||
if q.pattern.is_wildcard() {
|
if q.pattern.is_wildcard() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// As typed, exactly as the pass builds it: the matcher is
|
let folded = q.term.to_ascii_lowercase();
|
||||||
// case-insensitive in its own mask table.
|
let k = edit_budget(folded.len(), config.search.fuzzy_max_edits)?;
|
||||||
let k = edit_budget(q.term.len(), config.search.fuzzy_max_edits)?;
|
Bitap::new(folded.as_bytes(), k)
|
||||||
Bitap::new(q.term.as_bytes(), k)
|
|
||||||
});
|
});
|
||||||
self.config = Some(config);
|
self.config = Some(config);
|
||||||
|
|
||||||
|
|
@ -605,22 +593,20 @@ impl Loop {
|
||||||
let Some(text) = crate::file_handling::outcome_body(&outcome) else {
|
let Some(text) = crate::file_handling::outcome_body(&outcome) else {
|
||||||
return WindowUpdate::Unchanged;
|
return WindowUpdate::Unchanged;
|
||||||
};
|
};
|
||||||
|
let folded = text.to_ascii_lowercase();
|
||||||
let cut = match tier {
|
let cut = match tier {
|
||||||
ContentTier::Exact => {
|
ContentTier::Exact => {
|
||||||
// A literal term always yields a window, marked or not,
|
// A literal term always yields a window, marked or not,
|
||||||
// because the passes only ever call this for a body FTS
|
// because the passes only ever call this for a body FTS
|
||||||
// already matched. Here the body may genuinely have stopped
|
// already matched. Here the body may genuinely have stopped
|
||||||
// matching, and an unmarked window is how that reads.
|
// matching, and an unmarked window is how that reads.
|
||||||
//
|
|
||||||
// The fold is cut here rather than above the match, because
|
|
||||||
// only this arm needs one now — the fuzzy matcher folds in its
|
|
||||||
// own mask table.
|
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
crate::search::cascade::text_snippet(&query.pattern, text, &folded)
|
crate::search::cascade::text_snippet(&query.pattern, text, &folded)
|
||||||
.filter(|snip| !snip.ranges.is_empty())
|
.filter(|snip| !snip.ranges.is_empty())
|
||||||
}
|
}
|
||||||
ContentTier::Fuzzy => match &self.fuzzy {
|
ContentTier::Fuzzy => match &self.fuzzy {
|
||||||
Some(bitap) => crate::search::cascade::fuzzy_snippet(bitap, text).map(|(_, s)| s),
|
Some(bitap) => {
|
||||||
|
crate::search::cascade::fuzzy_snippet(bitap, text, &folded).map(|(_, s)| s)
|
||||||
|
}
|
||||||
// The term does not fuzz, so a fuzzy row cannot be re-judged;
|
// The term does not fuzz, so a fuzzy row cannot be re-judged;
|
||||||
// leaving it is the honest reading.
|
// leaving it is the honest reading.
|
||||||
None => return WindowUpdate::Unchanged,
|
None => return WindowUpdate::Unchanged,
|
||||||
|
|
@ -687,16 +673,8 @@ fn classify(
|
||||||
) {
|
) {
|
||||||
use notify::event::{ModifyKind, RenameMode};
|
use notify::event::{ModifyKind, RenameMode};
|
||||||
|
|
||||||
// `to_str`, not `to_string_lossy`. Targets are paths out of the index, so
|
let key = |p: &PathBuf| p.to_string_lossy().into_owned();
|
||||||
// they are all representable, and a path that is *not* still has a lossy
|
let is_target = |p: &PathBuf| targets.contains_key(&key(p));
|
||||||
// spelling — one that is a perfectly valid path for some other file. Keyed
|
|
||||||
// lossily, an event for a file we could never index would match a row we
|
|
||||||
// are displaying and fire a Gone or a Changed at it.
|
|
||||||
let target_key = |p: &PathBuf| {
|
|
||||||
p.to_str()
|
|
||||||
.filter(|k| targets.contains_key(*k))
|
|
||||||
.map(str::to_owned)
|
|
||||||
};
|
|
||||||
|
|
||||||
match event.kind {
|
match event.kind {
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
|
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
|
||||||
|
|
@ -706,55 +684,43 @@ fn classify(
|
||||||
let (Some(from), Some(to)) = (event.paths.first(), event.paths.get(1)) else {
|
let (Some(from), Some(to)) = (event.paths.first(), event.paths.get(1)) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if let Some(from_key) = target_key(from) {
|
if is_target(from) {
|
||||||
// A destination the index cannot spell cannot be recorded as a
|
pending.insert(key(from), Op::Renamed(to.clone()));
|
||||||
// rename: the row would carry a path naming a different file,
|
} else if is_target(to) {
|
||||||
// and the GUI opens rows by that path. The file did leave the
|
|
||||||
// searchable world, so `Gone` is the honest update — and the
|
|
||||||
// one the user would get anyway once the row was verified.
|
|
||||||
let op = match to.to_str() {
|
|
||||||
Some(_) => Op::Renamed(to.clone()),
|
|
||||||
None => Op::Gone,
|
|
||||||
};
|
|
||||||
pending.insert(from_key, op);
|
|
||||||
} else if let Some(to_key) = target_key(to) {
|
|
||||||
// The atomic-save shape: a temporary file renamed over a row
|
// The atomic-save shape: a temporary file renamed over a row
|
||||||
// we are watching. The row did not move; its contents changed.
|
// we are watching. The row did not move; its contents changed.
|
||||||
pending.insert(to_key, Op::Changed);
|
pending.insert(key(to), Op::Changed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
|
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
|
||||||
for path in &event.paths {
|
for path in &event.paths {
|
||||||
if let Some(key) = target_key(path) {
|
if is_target(path) {
|
||||||
pending.insert(key, Op::Changed);
|
pending.insert(key(path), Op::Changed);
|
||||||
} else if path.to_str().is_some() {
|
} else {
|
||||||
// Screened for the same reason: an orphan is paired with a
|
|
||||||
// lone `Gone` into a `Renamed` below, so an unrepresentable
|
|
||||||
// one would arrive at the same bad destination.
|
|
||||||
orphan_to.push(path.clone());
|
orphan_to.push(path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
|
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
|
||||||
for path in &event.paths {
|
for path in &event.paths {
|
||||||
if let Some(key) = target_key(path) {
|
if is_target(path) {
|
||||||
// Provisional; a Both in this same window upgrades it.
|
// Provisional; a Both in this same window upgrades it.
|
||||||
pending.entry(key).or_insert(Op::Gone);
|
pending.entry(key(path)).or_insert(Op::Gone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventKind::Remove(_) => {
|
EventKind::Remove(_) => {
|
||||||
for path in &event.paths {
|
for path in &event.paths {
|
||||||
if let Some(key) = target_key(path) {
|
if is_target(path) {
|
||||||
pending.insert(key, Op::Gone);
|
pending.insert(key(path), Op::Gone);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EventKind::Create(_) | EventKind::Modify(_) => {
|
EventKind::Create(_) | EventKind::Modify(_) => {
|
||||||
for path in &event.paths {
|
for path in &event.paths {
|
||||||
if let Some(key) = target_key(path) {
|
if is_target(path) {
|
||||||
// A Create at a watched path un-deletes the row.
|
// A Create at a watched path un-deletes the row.
|
||||||
pending.insert(key, Op::Changed);
|
pending.insert(key(path), Op::Changed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,92 +170,6 @@ fn an_ambiguous_windows_window_reports_gone_rather_than_guessing() {
|
||||||
assert_eq!(decided.get("/docs/b.txt"), Some(&Op::Gone));
|
assert_eq!(decided.get("/docs/b.txt"), Some(&Op::Gone));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`event`], for a path that cannot be spelled as a `&str`.
|
|
||||||
fn event_os(kind: EventKind, paths: &[PathBuf]) -> NotifyEvent {
|
|
||||||
NotifyEvent {
|
|
||||||
kind,
|
|
||||||
paths: paths.to_vec(),
|
|
||||||
attrs: Default::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A path in the displayed set, and its unrepresentable neighbour that
|
|
||||||
/// `to_string_lossy` collapses onto exactly that spelling.
|
|
||||||
fn twin_pair() -> (String, PathBuf) {
|
|
||||||
let shown = format!("/docs/{}", crate::testutil::lossy_twin("report", ".txt"));
|
|
||||||
let bad = PathBuf::from("/docs").join(crate::testutil::unrepresentable_name("report", ".txt"));
|
|
||||||
assert_eq!(bad.to_string_lossy(), shown, "the two must collide");
|
|
||||||
(shown, bad)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file the index could never hold still generates events, and its lossy
|
|
||||||
/// spelling is a displayed row's real path. Keyed lossily, every one of those
|
|
||||||
/// events lands on that row: a `Remove` of the file we cannot index would mark
|
|
||||||
/// a completely different file gone, on screen, while it sits there on disk.
|
|
||||||
#[test]
|
|
||||||
fn events_for_an_unrepresentable_path_never_touch_its_lossy_twin() {
|
|
||||||
let (shown, bad) = twin_pair();
|
|
||||||
let t = targets(&[&shown]);
|
|
||||||
let decided = window(
|
|
||||||
&t,
|
|
||||||
vec![
|
|
||||||
event_os(
|
|
||||||
EventKind::Remove(RemoveKind::File),
|
|
||||||
std::slice::from_ref(&bad),
|
|
||||||
),
|
|
||||||
event_os(
|
|
||||||
EventKind::Create(CreateKind::File),
|
|
||||||
std::slice::from_ref(&bad),
|
|
||||||
),
|
|
||||||
event_os(EventKind::Modify(ModifyKind::Any), &[bad]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
decided.is_empty(),
|
|
||||||
"the displayed row must be untouched: {decided:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Renamed *to* a name the index cannot spell, the row cannot keep a usable
|
|
||||||
/// path — `Op::Renamed` carries the destination, and the GUI opens rows by it,
|
|
||||||
/// so a lossy one would open some other file. It left the searchable world, so
|
|
||||||
/// the honest answer is `Gone`; dropping the event instead would leave a stale
|
|
||||||
/// row on screen until something else disturbed it.
|
|
||||||
#[test]
|
|
||||||
fn a_rename_to_an_unrepresentable_name_reports_gone() {
|
|
||||||
let (_, bad) = twin_pair();
|
|
||||||
let t = targets(&["/docs/a.txt"]);
|
|
||||||
let decided = window(
|
|
||||||
&t,
|
|
||||||
vec![event_os(
|
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
|
|
||||||
&[PathBuf::from("/docs/a.txt"), bad],
|
|
||||||
)],
|
|
||||||
);
|
|
||||||
assert_eq!(decided.get("/docs/a.txt"), Some(&Op::Gone));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The Windows shape of the same thing: the halves arrive separately, and a
|
|
||||||
/// lone `Gone` plus a lone arrival are paired into a rename. The arrival must
|
|
||||||
/// not be a path we cannot spell, or the pairing invents the same bad
|
|
||||||
/// destination the test above rejects.
|
|
||||||
#[test]
|
|
||||||
fn a_split_rename_is_not_paired_with_an_unrepresentable_arrival() {
|
|
||||||
let (_, bad) = twin_pair();
|
|
||||||
let t = targets(&["/docs/a.txt"]);
|
|
||||||
let decided = window(
|
|
||||||
&t,
|
|
||||||
vec![
|
|
||||||
event(
|
|
||||||
EventKind::Modify(ModifyKind::Name(RenameMode::From)),
|
|
||||||
&["/docs/a.txt"],
|
|
||||||
),
|
|
||||||
event_os(EventKind::Modify(ModifyKind::Name(RenameMode::To)), &[bad]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert_eq!(decided.get("/docs/a.txt"), Some(&Op::Gone));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A watched directory is full of files that are not on screen. None of them
|
/// A watched directory is full of files that are not on screen. None of them
|
||||||
/// may produce an update — live results never add rows.
|
/// may produce an update — live results never add rows.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -739,41 +653,3 @@ fn re_arming_drops_the_previous_targets() {
|
||||||
);
|
);
|
||||||
assert!(decided.is_empty(), "{decided:?}");
|
assert!(decided.is_empty(), "{decided:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `orphan_to` collects rename destinations whose source is not a displayed
|
|
||||||
/// row, and only `flush_settled` drains it — which used to run only when
|
|
||||||
/// `pending` was non-empty. Churn beside the results therefore pushed a
|
|
||||||
/// `PathBuf` that was never taken, and the stale entry then *paired* with the
|
|
||||||
/// next lone `Gone`: a deleted row reported as renamed to a file it has
|
|
||||||
/// nothing to do with, and the GUI following that path.
|
|
||||||
///
|
|
||||||
/// A file moved in from outside is the reliable way to leave a lone orphan on
|
|
||||||
/// every platform — there is no `From` half to pair it with, so no
|
|
||||||
/// `RenameMode::Both` follows.
|
|
||||||
#[test]
|
|
||||||
fn e2e_an_unpaired_move_in_does_not_capture_a_later_deletion() {
|
|
||||||
let dir = scratch_dir("live-orphan-drain");
|
|
||||||
let (watcher, rx, path) = watch_one(&dir, "before.txt");
|
|
||||||
|
|
||||||
// Move a file in from a directory nothing is watching: the watcher sees
|
|
||||||
// the destination and never a source.
|
|
||||||
let elsewhere = scratch_dir("live-orphan-source");
|
|
||||||
let outside = elsewhere.join("moved-in.txt");
|
|
||||||
std::fs::write(&outside, "unrelated").unwrap();
|
|
||||||
std::fs::rename(&outside, dir.join("moved-in.txt")).unwrap();
|
|
||||||
|
|
||||||
// Past the settle window, so the drain has had its chance.
|
|
||||||
std::thread::sleep(Duration::from_millis(600));
|
|
||||||
|
|
||||||
// Now delete the row that *is* on screen.
|
|
||||||
std::fs::remove_file(&path).unwrap();
|
|
||||||
|
|
||||||
let updates = collect(&rx, 1, Duration::from_secs(5));
|
|
||||||
stop_and_clean(watcher, &dir);
|
|
||||||
std::fs::remove_dir_all(&elsewhere).ok();
|
|
||||||
|
|
||||||
match updates.first() {
|
|
||||||
Some(LiveUpdate::Gone { path: gone, .. }) => assert_eq!(gone, &path),
|
|
||||||
other => panic!("a deletion must not pair with unrelated churn: {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -65,24 +65,6 @@ pub fn record(level: Level, message: String) {
|
||||||
Level::Warn => format!("Warning: {}", message),
|
Level::Warn => format!("Warning: {}", message),
|
||||||
Level::Info => message,
|
Level::Info => message,
|
||||||
};
|
};
|
||||||
// Almost every line here names a path, and a path is whatever someone
|
|
||||||
// called a file. An escape sequence in one reaches a terminal three ways:
|
|
||||||
// the stderr write below, a user running the GUI from a shell, and the
|
|
||||||
// Logs tab's Copy button, which puts the ring on the clipboard for pasting
|
|
||||||
// into a bug report. Diagnostics are not data — nothing downstream needs
|
|
||||||
// these bytes exactly — so they are scrubbed unconditionally.
|
|
||||||
//
|
|
||||||
// Line breaks are collapsed *first*, because `scrub_controls` counts them
|
|
||||||
// as controls and would leave `U+FFFD` where a space belongs. One record
|
|
||||||
// is one line — the ring renders it that way and `writeln!` adds the only
|
|
||||||
// newline there should be — so a message that arrives multi-line, such as
|
|
||||||
// a nested error's chain of causes, is flattened rather than boxed.
|
|
||||||
let text = if text.contains(['\n', '\r']) {
|
|
||||||
text.replace(['\n', '\r'], " ")
|
|
||||||
} else {
|
|
||||||
text
|
|
||||||
};
|
|
||||||
let text = crate::textenc::scrub_controls(&text).into_owned();
|
|
||||||
let _ = writeln!(std::io::stderr(), "{}", text);
|
let _ = writeln!(std::io::stderr(), "{}", text);
|
||||||
lock().push(level, text);
|
lock().push(level, text);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -407,15 +407,6 @@ pub const WATCH_ROOTS_RECURSIVELY: bool = cfg!(windows);
|
||||||
/// other with `LIKE` would otherwise disagree with itself. `NOCASE` folds ASCII
|
/// other with `LIKE` would otherwise disagree with itself. `NOCASE` folds ASCII
|
||||||
/// only, which matches what `LIKE` does — non-ASCII paths stay case-sensitive
|
/// only, which matches what `LIKE` does — non-ASCII paths stay case-sensitive
|
||||||
/// on both sides, consistently.
|
/// on both sides, consistently.
|
||||||
///
|
|
||||||
/// **No query interpolates this any more.** The folder filter was its last
|
|
||||||
/// caller, and it now needs one `LIKE` where it used to need `= COLLATE … OR
|
|
||||||
/// LIKE`. What is left is a *specification*: this constant and
|
|
||||||
/// `tests::collation_matches_like_case_folding` are where the rule is written
|
|
||||||
/// down, and the places that fold paths by hand —
|
|
||||||
/// [`PATHS_ARE_CASE_INSENSITIVE`], [`crate::config::IgnoreSet`],
|
|
||||||
/// [`crate::config::Config::is_index_file`] — are required to agree with it.
|
|
||||||
/// Deleting it would leave that agreement asserted nowhere.
|
|
||||||
pub const PATH_COLLATION: &str = if cfg!(windows) { "NOCASE" } else { "BINARY" };
|
pub const PATH_COLLATION: &str = if cfg!(windows) { "NOCASE" } else { "BINARY" };
|
||||||
|
|
||||||
/// Whether this platform's filesystem matches names without regard to case —
|
/// Whether this platform's filesystem matches names without regard to case —
|
||||||
|
|
@ -582,324 +573,6 @@ pub fn open_regular_file(path: &Path) -> std::io::Result<std::fs::File> {
|
||||||
Ok(file)
|
Ok(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why [`IndexLock::acquire`] did not hand back a lock.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum LockError {
|
|
||||||
/// Another process holds it. `pid` is whatever the holder recorded in the
|
|
||||||
/// file and is for the message only — it is never what decides.
|
|
||||||
Held { pid: Option<u32> },
|
|
||||||
/// The filesystem does not do locks. The caller must carry on regardless;
|
|
||||||
/// see [`IndexLock::acquire`].
|
|
||||||
Unsupported(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Proof that this process, and no other, owns the index at a given path.
|
|
||||||
///
|
|
||||||
/// Held for the life of the process: dropping it, or exiting, releases the
|
|
||||||
/// lock.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct IndexLock {
|
|
||||||
/// The lock lives on this open file description. Keeping the handle alive
|
|
||||||
/// is the whole mechanism, which is why the field is never read.
|
|
||||||
_file: std::fs::File,
|
|
||||||
path: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The lock this process is holding, if any.
|
|
||||||
///
|
|
||||||
/// A `static` because that is what the thing models: `flock` and `LockFileEx`
|
|
||||||
/// are owned by an open file description, one index is locked per process, and
|
|
||||||
/// what releases it is the process ending. Threading an `Arc<Mutex<_>>` from
|
|
||||||
/// `main` down through the unlock gate and into the settings handler would say
|
|
||||||
/// the same thing in three more places.
|
|
||||||
///
|
|
||||||
/// Never dropped — statics are not — and that costs nothing here: the kernel
|
|
||||||
/// releases the lock however the process goes away, which is the whole design.
|
|
||||||
static HELD_LOCK: std::sync::Mutex<Option<IndexLock>> = std::sync::Mutex::new(None);
|
|
||||||
|
|
||||||
impl IndexLock {
|
|
||||||
/// The lock file for the index at `db_path`.
|
|
||||||
///
|
|
||||||
/// Its own name, never the database or one of SQLite's sidecars: a
|
|
||||||
/// `flock` of ours on an inode SQLite also locks would be a second
|
|
||||||
/// locking protocol on one file, and on Unix our `close` of it would
|
|
||||||
/// cancel SQLite's locks — the very failure this whole change exists to
|
|
||||||
/// prevent.
|
|
||||||
pub fn path_for(db_path: &Path) -> PathBuf {
|
|
||||||
let name = db_path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("index.sqlite");
|
|
||||||
db_path.with_file_name(format!("{}.lock", name))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take the index lock, or report who has it.
|
|
||||||
///
|
|
||||||
/// **The guard is the kernel's lock, never the file's existence.** `flock`
|
|
||||||
/// and `LockFileEx` are held by the open file description, so the kernel
|
|
||||||
/// drops them when the process goes away for *any* reason — a clean exit,
|
|
||||||
/// a panic, SIGKILL, the OOM killer, a SIGBUS, or the power going out. A
|
|
||||||
/// `.lock` file left behind by an unclean shutdown is therefore inert, and
|
|
||||||
/// the next start re-acquires it normally. Nothing here may ever branch on
|
|
||||||
/// the file being present; a stale-PID-file scheme would strand the user
|
|
||||||
/// behind a crash exactly when they most want to reopen the app.
|
|
||||||
///
|
|
||||||
/// [`LockError::Unsupported`] means the filesystem could not answer, not
|
|
||||||
/// that the lock is taken — NFS without `lockd`, some FUSE mounts, a few
|
|
||||||
/// network shares. Callers **must** start anyway: a convenience guard is
|
|
||||||
/// never a good enough reason to refuse to open.
|
|
||||||
pub fn acquire(db_path: &Path) -> Result<IndexLock, LockError> {
|
|
||||||
let path = IndexLock::path_for(db_path);
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
if !dir.as_os_str().is_empty() {
|
|
||||||
let _ = create_dir_private(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let file = match std::fs::OpenOptions::new()
|
|
||||||
.read(true)
|
|
||||||
.write(true)
|
|
||||||
.create(true)
|
|
||||||
.truncate(false)
|
|
||||||
.open(&path)
|
|
||||||
{
|
|
||||||
Ok(f) => f,
|
|
||||||
// A read-only or unwritable index directory is not a second
|
|
||||||
// instance. Same rule as an unsupported filesystem: carry on.
|
|
||||||
Err(e) => return Err(LockError::Unsupported(format!("{}: {}", path.display(), e))),
|
|
||||||
};
|
|
||||||
lock_exclusive_nonblocking(&file).map_err(|e| match e {
|
|
||||||
LockAttempt::Held => LockError::Held {
|
|
||||||
pid: read_recorded_pid(&path),
|
|
||||||
},
|
|
||||||
LockAttempt::Unsupported(msg) => LockError::Unsupported(msg),
|
|
||||||
})?;
|
|
||||||
let lock = IndexLock {
|
|
||||||
_file: file,
|
|
||||||
path: path.clone(),
|
|
||||||
};
|
|
||||||
// Only after the lock is ours, and only so a second instance can name
|
|
||||||
// us in its message. Nothing reads this to make a decision.
|
|
||||||
lock.record_holder();
|
|
||||||
Ok(lock)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`IndexLock::acquire`], keeping the lock in [`HELD_LOCK`] for the life
|
|
||||||
/// of the process. What a frontend calls at startup.
|
|
||||||
pub fn hold(db_path: &Path) -> Result<(), LockError> {
|
|
||||||
let lock = IndexLock::acquire(db_path)?;
|
|
||||||
*crate::lock_ok(&HELD_LOCK) = Some(lock);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the held lock onto the index at `db_path`, for a `database_path`
|
|
||||||
/// that changed while the app was running.
|
|
||||||
///
|
|
||||||
/// **The new lock is taken before the old one is let go.** A refusal then
|
|
||||||
/// leaves this process holding exactly what it held before, so the caller
|
|
||||||
/// can reject the change and stay in a state that is still true. Releasing
|
|
||||||
/// first would leave a window in which another instance could claim the
|
|
||||||
/// index we are about to keep using.
|
|
||||||
///
|
|
||||||
/// Naming the index already locked is [`LockError::Held`] and changes
|
|
||||||
/// nothing. [`LockError::Unsupported`] means the move *happened* — the old
|
|
||||||
/// lock is gone — and the new path simply cannot be locked; carry on, as at
|
|
||||||
/// startup. Holding the old path's lock in that case would be worse than
|
|
||||||
/// holding none: it guards a file this process no longer uses while barring
|
|
||||||
/// a second instance from it.
|
|
||||||
pub fn move_to(db_path: &Path) -> Result<(), LockError> {
|
|
||||||
let mut slot = crate::lock_ok(&HELD_LOCK);
|
|
||||||
// Short-circuit before acquiring: `flock` conflicts with itself across
|
|
||||||
// two open file descriptions even inside one process, so re-taking the
|
|
||||||
// path we already hold would report itself as `Held`.
|
|
||||||
if slot
|
|
||||||
.as_ref()
|
|
||||||
.is_some_and(|held| held.path == IndexLock::path_for(db_path))
|
|
||||||
{
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
match IndexLock::acquire(db_path) {
|
|
||||||
Ok(lock) => {
|
|
||||||
*slot = Some(lock);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(LockError::Unsupported(why)) => {
|
|
||||||
*slot = None;
|
|
||||||
Err(LockError::Unsupported(why))
|
|
||||||
}
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn record_holder(&self) {
|
|
||||||
use std::io::Write;
|
|
||||||
// A fresh handle: writing through `_file` would move the shared file
|
|
||||||
// offset the lock has nothing to do with, and the failure here is
|
|
||||||
// cosmetic either way. That a *second* handle can write here at all is
|
|
||||||
// why the Windows lock byte sits at [`LOCK_BYTE_OFFSET`] rather than at
|
|
||||||
// offset 0 — its byte-range locks are mandatory and per-handle, so a
|
|
||||||
// lock over this byte would block our own write.
|
|
||||||
let written = std::fs::OpenOptions::new()
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.open(&self.path)
|
|
||||||
.and_then(|mut f| write!(f, "{}", std::process::id()));
|
|
||||||
if let Err(e) = written {
|
|
||||||
crate::log_warn!(
|
|
||||||
"could not record the lock holder in {}: {}",
|
|
||||||
self.path.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The PID a holder recorded, for a message. `None` whenever the file is
|
|
||||||
/// absent, empty or unparseable — all of which are ordinary.
|
|
||||||
fn read_recorded_pid(path: &Path) -> Option<u32> {
|
|
||||||
std::fs::read_to_string(path).ok()?.trim().parse().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What one attempt at the lock found.
|
|
||||||
enum LockAttempt {
|
|
||||||
Held,
|
|
||||||
Unsupported(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the Windows lock byte lives, chosen to be nowhere near the PID the
|
|
||||||
/// lock file holds at offset 0. See [`lock_exclusive_nonblocking`]'s Windows
|
|
||||||
/// arm; the Unix arm has no equivalent, because `flock` locks the open file
|
|
||||||
/// description rather than a byte range.
|
|
||||||
#[cfg(windows)]
|
|
||||||
const LOCK_BYTE_OFFSET: u64 = 1 << 63;
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn lock_exclusive_nonblocking(file: &std::fs::File) -> Result<(), LockAttempt> {
|
|
||||||
use std::os::unix::io::AsRawFd;
|
|
||||||
|
|
||||||
// `flock`, not `fcntl`: a POSIX record lock would be cancelled by any
|
|
||||||
// `close` this process makes on the same inode, which is the hazard
|
|
||||||
// documented in `file_handling::index_file_set`. A `flock` belongs to the
|
|
||||||
// open file description and is immune to it.
|
|
||||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
||||||
if rc == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let err = std::io::Error::last_os_error();
|
|
||||||
match err.raw_os_error() {
|
|
||||||
Some(libc::EWOULDBLOCK) => Err(LockAttempt::Held),
|
|
||||||
_ => Err(LockAttempt::Unsupported(err.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn lock_exclusive_nonblocking(file: &std::fs::File) -> Result<(), LockAttempt> {
|
|
||||||
use std::os::windows::io::AsRawHandle;
|
|
||||||
use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION;
|
|
||||||
use windows_sys::Win32::Storage::FileSystem::{
|
|
||||||
LockFileEx, LOCKFILE_EXCLUSIVE_LOCK, LOCKFILE_FAIL_IMMEDIATELY,
|
|
||||||
};
|
|
||||||
use windows_sys::Win32::System::IO::{OVERLAPPED, OVERLAPPED_0, OVERLAPPED_0_0};
|
|
||||||
|
|
||||||
// The locked byte sits at [`LOCK_BYTE_OFFSET`], far past anything this
|
|
||||||
// file will ever hold — **not** at offset 0, where the PID is written.
|
|
||||||
// Windows byte-range locks are *mandatory* and belong to the file object,
|
|
||||||
// not the process: a lock covering offset 0 makes our own
|
|
||||||
// `record_holder`, which writes through a second handle, fail with
|
|
||||||
// `ERROR_LOCK_VIOLATION`, and `read_recorded_pid` fail the same way — so
|
|
||||||
// the "already running" message would lose the PID on the one platform
|
|
||||||
// that has no terminal to print it to. Locking beyond end-of-file is
|
|
||||||
// explicitly legal and is the conventional way to use a file as a
|
|
||||||
// semaphore.
|
|
||||||
let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() };
|
|
||||||
overlapped.Anonymous = OVERLAPPED_0 {
|
|
||||||
Anonymous: OVERLAPPED_0_0 {
|
|
||||||
Offset: LOCK_BYTE_OFFSET as u32,
|
|
||||||
OffsetHigh: (LOCK_BYTE_OFFSET >> 32) as u32,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let ok = unsafe {
|
|
||||||
LockFileEx(
|
|
||||||
file.as_raw_handle() as _,
|
|
||||||
LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
0,
|
|
||||||
&mut overlapped,
|
|
||||||
)
|
|
||||||
};
|
|
||||||
if ok != 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let err = std::io::Error::last_os_error();
|
|
||||||
match err.raw_os_error() {
|
|
||||||
Some(code) if code == ERROR_LOCK_VIOLATION as i32 => Err(LockAttempt::Held),
|
|
||||||
_ => Err(LockAttempt::Unsupported(err.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bytes free to this user on the filesystem holding `path`, or `None` where
|
|
||||||
/// the platform will not say.
|
|
||||||
///
|
|
||||||
/// `None` is "unknown", never "zero": every caller treats it as no reason to
|
|
||||||
/// hold anything back.
|
|
||||||
///
|
|
||||||
/// The path need not exist — its nearest existing ancestor is what gets
|
|
||||||
/// asked, so this answers for a database that has not been created yet.
|
|
||||||
pub fn available_space(path: &Path) -> Option<u64> {
|
|
||||||
let mut probe = path;
|
|
||||||
loop {
|
|
||||||
if probe.exists() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
probe = probe.parent()?;
|
|
||||||
}
|
|
||||||
available_space_of_existing(probe)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn available_space_of_existing(path: &Path) -> Option<u64> {
|
|
||||||
use std::ffi::CString;
|
|
||||||
use std::os::unix::ffi::OsStrExt;
|
|
||||||
|
|
||||||
let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
|
|
||||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
|
||||||
if unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } != 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// `f_bavail`, not `f_bfree`: the reserved blocks in the difference are
|
|
||||||
// root's, and this process is not root. `f_frsize` is the fragment size
|
|
||||||
// the block counts are in — `f_bsize` is the preferred I/O size and is
|
|
||||||
// the wrong multiplier.
|
|
||||||
let frsize = if stat.f_frsize > 0 {
|
|
||||||
stat.f_frsize
|
|
||||||
} else {
|
|
||||||
stat.f_bsize
|
|
||||||
};
|
|
||||||
(stat.f_bavail as u64).checked_mul(frsize as u64)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn available_space_of_existing(path: &Path) -> Option<u64> {
|
|
||||||
use std::os::windows::ffi::OsStrExt;
|
|
||||||
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
|
||||||
|
|
||||||
let wide: Vec<u16> = path
|
|
||||||
.as_os_str()
|
|
||||||
.encode_wide()
|
|
||||||
.chain(std::iter::once(0))
|
|
||||||
.collect();
|
|
||||||
let mut free_to_caller: u64 = 0;
|
|
||||||
let ok = unsafe {
|
|
||||||
GetDiskFreeSpaceExW(
|
|
||||||
wide.as_ptr(),
|
|
||||||
&mut free_to_caller,
|
|
||||||
std::ptr::null_mut(),
|
|
||||||
std::ptr::null_mut(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
(ok != 0).then_some(free_to_caller)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long to keep retrying a delete that fails because something else holds
|
/// How long to keep retrying a delete that fails because something else holds
|
||||||
/// the file open.
|
/// the file open.
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unc_spellings() {
|
fn unc_spellings() {
|
||||||
assert!(is_unc_string(r"\\server\share"));
|
assert!(is_unc_string(r"\\server\share"));
|
||||||
|
|
@ -244,240 +242,3 @@ fn sep_prefix() -> String {
|
||||||
"/".to_string()
|
"/".to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two live instances cannot both hold the index.
|
|
||||||
#[test]
|
|
||||||
fn the_index_lock_is_exclusive_while_held() {
|
|
||||||
let db = crate::testutil::scratch_dir("lock-excl").join("index.sqlite");
|
|
||||||
let first = IndexLock::acquire(&db).expect("first acquire");
|
|
||||||
match IndexLock::acquire(&db) {
|
|
||||||
Err(LockError::Held { pid }) => {
|
|
||||||
// Recorded for the message only, but it should name us.
|
|
||||||
assert_eq!(pid, Some(std::process::id()));
|
|
||||||
}
|
|
||||||
Err(LockError::Unsupported(why)) => {
|
|
||||||
// A filesystem with no locks cannot answer; nothing to assert.
|
|
||||||
eprintln!("skipping: {}", why);
|
|
||||||
}
|
|
||||||
Ok(_) => panic!("the lock was handed out twice"),
|
|
||||||
}
|
|
||||||
drop(first);
|
|
||||||
// And it comes back once the holder lets go — but not necessarily in the
|
|
||||||
// same instant, which is why this retries instead of asserting outright.
|
|
||||||
//
|
|
||||||
// `flock` belongs to the *open file description*, and `fork` duplicates
|
|
||||||
// the descriptor table: between another thread's `fork` and its `exec`,
|
|
||||||
// the child shares every description this process has open, including the
|
|
||||||
// one we just released. `O_CLOEXEC` closes it at `exec` — verified, no
|
|
||||||
// descriptor survives into a spawned child — but until then the lock
|
|
||||||
// stays held. Several tests in this suite spawn processes (the sibling
|
|
||||||
// test below, and `file_handling::counting`'s `find`/`wc`), so under
|
|
||||||
// `cargo test` this window is reached often enough to be seen.
|
|
||||||
//
|
|
||||||
// It cannot reach the product: the lock is taken once at startup and held
|
|
||||||
// for the life of the process, never dropped and immediately retaken.
|
|
||||||
acquire_within(&db, Duration::from_secs(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`IndexLock::acquire`], retried past the `fork`/`exec` window described in
|
|
||||||
/// [`the_index_lock_is_exclusive_while_held`].
|
|
||||||
fn acquire_within(db: &std::path::Path, budget: Duration) -> IndexLock {
|
|
||||||
let deadline = Instant::now() + budget;
|
|
||||||
loop {
|
|
||||||
match IndexLock::acquire(db) {
|
|
||||||
Ok(lock) => return lock,
|
|
||||||
Err(e) if Instant::now() >= deadline => panic!("never acquired: {:?}", e),
|
|
||||||
Err(_) => std::thread::sleep(Duration::from_millis(10)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Env var naming the database whose lock [`lock_holder_child`] should take.
|
|
||||||
/// Absent in an ordinary run, which is what makes that test a no-op.
|
|
||||||
const LOCK_CHILD_DB: &str = "QS_LOCK_CHILD_DB";
|
|
||||||
|
|
||||||
/// **A crash must not lock the user out.**
|
|
||||||
///
|
|
||||||
/// Nothing unlinks the lock file, so it outlives an unclean exit. If startup
|
|
||||||
/// keyed on the file *existing*, one SIGKILL — or the SIGBUS this whole change
|
|
||||||
/// is about — would leave QuickSearch permanently unopenable. The guard is the
|
|
||||||
/// kernel's `flock`/`LockFileEx`, released when the holder's handle goes away
|
|
||||||
/// however it goes away, so the leftover file is inert.
|
|
||||||
///
|
|
||||||
/// Only a real killed process proves that, so this spawns one: nothing a
|
|
||||||
/// single process can do to itself reproduces "died without running a
|
|
||||||
/// destructor" while leaving a test alive to check the result.
|
|
||||||
#[test]
|
|
||||||
fn a_killed_holder_does_not_block_the_next_start() {
|
|
||||||
let db = crate::testutil::scratch_dir("lock-crash").join("index.sqlite");
|
|
||||||
let lock_path = IndexLock::path_for(&db);
|
|
||||||
|
|
||||||
// Probe first: on a filesystem without locks there is nothing to test.
|
|
||||||
match IndexLock::acquire(&db) {
|
|
||||||
Ok(lock) => drop(lock),
|
|
||||||
Err(LockError::Unsupported(why)) => {
|
|
||||||
eprintln!("skipping: {}", why);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(LockError::Held { .. }) => panic!("a fresh path cannot be held"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let exe = std::env::current_exe().expect("test binary path");
|
|
||||||
let mut child = std::process::Command::new(exe)
|
|
||||||
.args([
|
|
||||||
"--exact",
|
|
||||||
"platform::tests::lock_holder_child",
|
|
||||||
"--nocapture",
|
|
||||||
])
|
|
||||||
.env(LOCK_CHILD_DB, &db)
|
|
||||||
.spawn()
|
|
||||||
.expect("spawn the lock holder");
|
|
||||||
|
|
||||||
// Poll rather than read a pipe: a child that dies early then fails this
|
|
||||||
// test at the deadline instead of hanging it forever.
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(30);
|
|
||||||
loop {
|
|
||||||
if matches!(IndexLock::acquire(&db), Err(LockError::Held { .. })) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if Instant::now() >= deadline {
|
|
||||||
let _ = child.kill();
|
|
||||||
panic!("the child never took the lock");
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_millis(25));
|
|
||||||
}
|
|
||||||
|
|
||||||
// SIGKILL / TerminateProcess: no unwinding, no destructors, no cleanup —
|
|
||||||
// exactly what a SIGBUS leaves behind.
|
|
||||||
child.kill().expect("kill the holder");
|
|
||||||
child.wait().expect("reap the holder");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
lock_path.exists(),
|
|
||||||
"the crash should leave the lock file at {}",
|
|
||||||
lock_path.display()
|
|
||||||
);
|
|
||||||
// The point of the whole test: file present, holder dead, start succeeds.
|
|
||||||
// Retried for the reason `acquire_within` documents, not because a dead
|
|
||||||
// holder could still be holding anything.
|
|
||||||
acquire_within(&db, Duration::from_secs(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The child half of [`a_killed_holder_does_not_block_the_next_start`]: take
|
|
||||||
/// the lock, then wait to be killed. A no-op in an ordinary run.
|
|
||||||
#[test]
|
|
||||||
fn lock_holder_child() {
|
|
||||||
let Some(db) = std::env::var_os(LOCK_CHILD_DB) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let db = std::path::PathBuf::from(db);
|
|
||||||
// The parent probes the lock to find out when we have it, so it may hold
|
|
||||||
// it for an instant just as we ask. Retry rather than lose the race.
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(30);
|
|
||||||
let _lock = loop {
|
|
||||||
match IndexLock::acquire(&db) {
|
|
||||||
Ok(lock) => break lock,
|
|
||||||
Err(e) if Instant::now() >= deadline => panic!("child never acquired: {:?}", e),
|
|
||||||
Err(_) => std::thread::sleep(Duration::from_millis(10)),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Killed long before this returns. The sleep is a backstop so a parent
|
|
||||||
// that dies first cannot strand this process.
|
|
||||||
std::thread::sleep(Duration::from_secs(120));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `database_path` changed in Settings must carry the lock with it — and a
|
|
||||||
/// refused move must leave this process holding exactly what it held.
|
|
||||||
///
|
|
||||||
/// The whole point of taking the new lock before dropping the old: if the
|
|
||||||
/// destination is already somebody else's, the settings change is rejected and
|
|
||||||
/// the app goes on using the index it was using, still guarded. Releasing
|
|
||||||
/// first would open a window on the index we are about to keep writing to.
|
|
||||||
///
|
|
||||||
/// Uses the process-wide slot, so it is the one test that touches
|
|
||||||
/// [`HELD_LOCK`]; the paths are per-test scratch directories, so it does not
|
|
||||||
/// race the sibling tests that call [`IndexLock::acquire`] directly.
|
|
||||||
#[test]
|
|
||||||
fn the_held_lock_follows_the_database_path() {
|
|
||||||
let dir = crate::testutil::scratch_dir("lock-move");
|
|
||||||
let first = dir.join("first.sqlite");
|
|
||||||
let second = dir.join("second.sqlite");
|
|
||||||
|
|
||||||
// A filesystem with no locks cannot answer any of this.
|
|
||||||
match IndexLock::hold(&first) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(LockError::Unsupported(why)) => {
|
|
||||||
eprintln!("skipping: {}", why);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Err(LockError::Held { .. }) => panic!("a fresh path cannot be held"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Naming the index we already hold is a no-op, not a self-collision:
|
|
||||||
// `flock` conflicts with itself across two descriptions in one process.
|
|
||||||
IndexLock::move_to(&first).expect("re-holding the same path");
|
|
||||||
|
|
||||||
// Somebody else owns the destination, so the move is refused...
|
|
||||||
let rival = acquire_within(&second, Duration::from_secs(5));
|
|
||||||
assert!(
|
|
||||||
matches!(IndexLock::move_to(&second), Err(LockError::Held { .. })),
|
|
||||||
"a held destination must refuse the move"
|
|
||||||
);
|
|
||||||
// ...and the old path is still ours, which is what lets the caller reject
|
|
||||||
// the settings change and stay correct.
|
|
||||||
assert!(
|
|
||||||
matches!(IndexLock::acquire(&first), Err(LockError::Held { .. })),
|
|
||||||
"the original lock must survive a refused move"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Once the destination frees up the move goes through, and the path we
|
|
||||||
// came from is released.
|
|
||||||
drop(rival);
|
|
||||||
let deadline = Instant::now() + Duration::from_secs(5);
|
|
||||||
while IndexLock::move_to(&second).is_err() && Instant::now() < deadline {
|
|
||||||
std::thread::sleep(Duration::from_millis(10));
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
matches!(IndexLock::acquire(&second), Err(LockError::Held { .. })),
|
|
||||||
"the new path must be held after the move"
|
|
||||||
);
|
|
||||||
acquire_within(&first, Duration::from_secs(5));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `database_path` in hand, the lock is a sibling with its own name — never
|
|
||||||
/// the database or one of SQLite's sidecars, whose inodes we must not touch.
|
|
||||||
#[test]
|
|
||||||
fn the_lock_file_is_not_the_database_or_a_sidecar() {
|
|
||||||
let db = std::path::Path::new("/var/lib/qs/index.sqlite");
|
|
||||||
let lock = IndexLock::path_for(db);
|
|
||||||
assert_eq!(lock, std::path::Path::new("/var/lib/qs/index.sqlite.lock"));
|
|
||||||
for suffix in crate::file_handling::INDEX_SIDECAR_SUFFIXES {
|
|
||||||
if suffix == ".lock" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
assert_ne!(
|
|
||||||
lock,
|
|
||||||
std::path::PathBuf::from(format!("{}{}", db.display(), suffix))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_ne!(lock, db);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn available_space_answers_for_a_real_directory() {
|
|
||||||
let dir = crate::testutil::scratch_dir("space");
|
|
||||||
let free = available_space(&dir).expect("temp dir has a filesystem");
|
|
||||||
assert!(free > 0, "a writable scratch dir should have free space");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The database is asked about before it exists — the check runs at the start
|
|
||||||
/// of the first run, when nothing has created the file yet.
|
|
||||||
#[test]
|
|
||||||
fn available_space_walks_up_to_an_existing_ancestor() {
|
|
||||||
let missing = crate::testutil::scratch_dir("space-missing")
|
|
||||||
.join("not")
|
|
||||||
.join("created")
|
|
||||||
.join("index.sqlite");
|
|
||||||
assert!(!missing.exists());
|
|
||||||
assert!(available_space(&missing).is_some());
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -174,19 +174,6 @@ impl TermPattern {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`literal`](Self::literal), ASCII-folded — the form every
|
|
||||||
/// case-insensitive scan actually searches with.
|
|
||||||
///
|
|
||||||
/// Handing this out rather than folding at the call site matters because
|
|
||||||
/// the full-text pass calls it once per candidate row: the pattern built
|
|
||||||
/// this string once, when the query was parsed.
|
|
||||||
pub fn literal_folded(&self) -> Option<&str> {
|
|
||||||
match self {
|
|
||||||
TermPattern::Literal(l) => Some(&l.folded),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Literal chunks between wildcards (the whole term when literal).
|
/// Literal chunks between wildcards (the whole term when literal).
|
||||||
pub fn segments(&self) -> &[String] {
|
pub fn segments(&self) -> &[String] {
|
||||||
match self {
|
match self {
|
||||||
|
|
@ -302,12 +289,8 @@ impl TermPattern {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Non-overlapping occurrence count.
|
/// Non-overlapping occurrence count, capped at 1000 (the cascade's
|
||||||
///
|
/// `count_frac` saturates there anyway).
|
||||||
/// Wildcards stop at 1000 — the cascade's `count_frac` saturates there
|
|
||||||
/// anyway, and each regex match costs far more than a `memmem` hit.
|
|
||||||
/// Literals are counted in full, because stopping early would cost a
|
|
||||||
/// branch on the one path that runs over every candidate body.
|
|
||||||
pub fn count(&self, text: &str, case_insensitive: bool) -> usize {
|
pub fn count(&self, text: &str, case_insensitive: bool) -> usize {
|
||||||
match self {
|
match self {
|
||||||
TermPattern::Empty => 0,
|
TermPattern::Empty => 0,
|
||||||
|
|
@ -331,84 +314,6 @@ impl TermPattern {
|
||||||
pub struct RegexQuery {
|
pub struct RegexQuery {
|
||||||
pub source: String,
|
pub source: String,
|
||||||
re: Regex,
|
re: Regex,
|
||||||
/// Literals of which at least one must occur in anything this matches, when
|
|
||||||
/// the pattern admits such a set. See [`RegexQuery::required`].
|
|
||||||
required: Option<crate::search::prefilter::Required>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract a set of literals at least one of which occurs in every match.
|
|
||||||
///
|
|
||||||
/// This is the same analysis the regex engine performs to build its own
|
|
||||||
/// prefilter, run for the same reason a level up: the `regex:` passes scan every
|
|
||||||
/// name, every path and every stored document, and a required literal lets
|
|
||||||
/// SQLite and the trigram index reject most of those rows first.
|
|
||||||
///
|
|
||||||
/// # What makes it sound
|
|
||||||
///
|
|
||||||
/// A *prefix* set has the property that every match begins with one of its
|
|
||||||
/// literals; a *suffix* set, that every match ends with one. Either way the
|
|
||||||
/// matched text — itself a substring of the field being searched — contains that
|
|
||||||
/// literal, so the field does. When the set is unbounded (`\d+`, `.*foo` by
|
|
||||||
/// prefix) `literals()` answers `None` and there is nothing to filter on.
|
|
||||||
///
|
|
||||||
/// Both kinds are tried because they fail on opposite patterns: `foo.*` has a
|
|
||||||
/// usable prefix and no usable suffix, `.*foo` the reverse. The more selective
|
|
||||||
/// of the two wins, measured by the shortest literal each would force a scan to
|
|
||||||
/// match — the weakest link in an OR.
|
|
||||||
///
|
|
||||||
/// # Why it parses case-sensitively
|
|
||||||
///
|
|
||||||
/// The query itself is case-insensitive, and extracting from a case-insensitive
|
|
||||||
/// pattern expands combinatorially — `(?i)FOO` comes back as eight literals, and
|
|
||||||
/// a longer word simply exceeds the extractor's budget and yields nothing.
|
|
||||||
/// Parsing without the flag gives one clean literal instead.
|
|
||||||
///
|
|
||||||
/// That is still sound. A case-insensitive match of `foo` against text `FOO`
|
|
||||||
/// means the text holds *some* case variant of the literal, and both consumers
|
|
||||||
/// fold: the trigram index lowercases what it indexes, and `LIKE` is
|
|
||||||
/// ASCII-case-insensitive by SQLite's default collation. An inline `(?i)` inside
|
|
||||||
/// the pattern is honoured by the parser regardless, so it explodes and yields
|
|
||||||
/// `None` — a lost optimisation, never a lost row.
|
|
||||||
fn required_literals(source: &str) -> Option<crate::search::prefilter::Required> {
|
|
||||||
use regex_syntax::hir::literal::{ExtractKind, Extractor};
|
|
||||||
|
|
||||||
let hir = regex_syntax::ParserBuilder::new()
|
|
||||||
.case_insensitive(false)
|
|
||||||
.build()
|
|
||||||
.parse(source)
|
|
||||||
.ok()?;
|
|
||||||
|
|
||||||
let mut best: Option<(usize, crate::search::prefilter::Required)> = None;
|
|
||||||
for kind in [ExtractKind::Prefix, ExtractKind::Suffix] {
|
|
||||||
let seq = Extractor::new().kind(kind).extract(&hir);
|
|
||||||
let Some(literals) = seq.literals() else {
|
|
||||||
continue; // unbounded: no constraint to be had from this direction
|
|
||||||
};
|
|
||||||
// A literal is bytes, and the extractor can split a multi-byte character
|
|
||||||
// across the boundary of what it kept. Anything that is not valid UTF-8
|
|
||||||
// cannot be handed to FTS5 or bound as SQL text, so the whole direction
|
|
||||||
// is abandoned rather than filtered — dropping one literal from an OR
|
|
||||||
// would drop the rows only it covers.
|
|
||||||
let strings: Option<Vec<String>> = literals
|
|
||||||
.iter()
|
|
||||||
.map(|l| std::str::from_utf8(l.as_bytes()).ok().map(str::to_owned))
|
|
||||||
.collect();
|
|
||||||
let Some(strings) = strings else { continue };
|
|
||||||
let Some(required) = crate::search::prefilter::Required::new(strings) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// The OR is only as selective as its shortest arm.
|
|
||||||
let weakest = required
|
|
||||||
.literals()
|
|
||||||
.iter()
|
|
||||||
.map(|l| l.chars().count())
|
|
||||||
.min()
|
|
||||||
.unwrap_or(0);
|
|
||||||
if best.as_ref().is_none_or(|(w, _)| weakest > *w) {
|
|
||||||
best = Some((weakest, required));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
best.map(|(_, required)| required)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RegexQuery {
|
impl RegexQuery {
|
||||||
|
|
@ -430,23 +335,9 @@ impl RegexQuery {
|
||||||
Ok(RegexQuery {
|
Ok(RegexQuery {
|
||||||
source: source.to_string(),
|
source: source.to_string(),
|
||||||
re,
|
re,
|
||||||
// Once per query, never per row. A pattern that yields nothing
|
|
||||||
// simply scans, exactly as every `regex:` query used to.
|
|
||||||
required: required_literals(source),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Literals of which at least one occurs in anything this matches, when the
|
|
||||||
/// pattern admits such a set.
|
|
||||||
///
|
|
||||||
/// The `regex:` passes use it to narrow what they scan; `None` means the
|
|
||||||
/// pattern constrains nothing usable (`\d+`, `a.*b`) and the pass reads
|
|
||||||
/// everything, which is what it always did. See
|
|
||||||
/// [`crate::search::prefilter`] for the rule a prefilter has to obey.
|
|
||||||
pub fn required(&self) -> Option<&crate::search::prefilter::Required> {
|
|
||||||
self.required.as_ref()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_match(&self, text: &str) -> bool {
|
pub fn is_match(&self, text: &str) -> bool {
|
||||||
self.re.is_match(text)
|
self.re.is_match(text)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -291,14 +291,11 @@ mod tests {
|
||||||
fn a_windows_drive_path_reaches_the_filter_intact() {
|
fn a_windows_drive_path_reaches_the_filter_intact() {
|
||||||
let q = split_for_cascade(r"path:C:\Users\me\docs").unwrap();
|
let q = split_for_cascade(r"path:C:\Users\me\docs").unwrap();
|
||||||
assert_eq!(q.term, "", "the whole input is a filter");
|
assert_eq!(q.term, "", "the whole input is a filter");
|
||||||
// The folder filter binds one value: the subtree LIKE pattern built
|
assert!(
|
||||||
// from the spelling the user typed. Asserted through the builder so
|
matches!(
|
||||||
// this stays about the plumbing rather than about escaping rules.
|
&q.filter_params[0],
|
||||||
assert_eq!(
|
Value::Text(t) if t == r"C:\Users\me\docs"
|
||||||
q.filter_params[0],
|
),
|
||||||
Value::Text(crate::query::translator::like_subtree_pattern(
|
|
||||||
r"C:\Users\me\docs"
|
|
||||||
)),
|
|
||||||
"{:?}",
|
"{:?}",
|
||||||
q.filter_params
|
q.filter_params
|
||||||
);
|
);
|
||||||
|
|
@ -310,9 +307,7 @@ mod tests {
|
||||||
assert_eq!(q.term, "");
|
assert_eq!(q.term, "");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
q.filter_params[0],
|
q.filter_params[0],
|
||||||
Value::Text(crate::query::translator::like_subtree_pattern(
|
Value::Text("/home/me/My Documents".into())
|
||||||
"/home/me/My Documents"
|
|
||||||
))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -444,12 +439,9 @@ mod tests {
|
||||||
let q = split_for_cascade("name:%*_").unwrap();
|
let q = split_for_cascade("name:%*_").unwrap();
|
||||||
assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%%\\_%"));
|
assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%%\\_%"));
|
||||||
|
|
||||||
// path: values never glob — the star reaches the pattern as a literal.
|
// path: values never glob.
|
||||||
let q = split_for_cascade("path:/da*ta").unwrap();
|
let q = split_for_cascade("path:/da*ta").unwrap();
|
||||||
assert_eq!(
|
assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "/da*ta"));
|
||||||
q.filter_params[0],
|
|
||||||
Value::Text(crate::query::translator::like_subtree_pattern("/da*ta"))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -146,15 +146,18 @@ pub fn build_filter(
|
||||||
// single root, so say it directly rather than by accident.
|
// single root, so say it directly rather than by accident.
|
||||||
return Ok(frag("1=1", Vec::new()));
|
return Ok(frag("1=1", Vec::new()));
|
||||||
}
|
}
|
||||||
// One `LIKE`, where this used to need `parent = ? OR parent LIKE ?`
|
// The `=` half needs the collation spelled out: `LIKE` folds ASCII
|
||||||
// with the collation spelled out to stop the two halves disagreeing
|
// case on its own, so without this the two halves of the same
|
||||||
// about `C:\Users` versus `c:\users`. Every stored parent now ends
|
// filter disagree about `C:\Users` versus `c:\users`.
|
||||||
// in a separator, so `dir + SEP + %` matches the folder's own files
|
|
||||||
// (`%` matching nothing) as well as its subdirectories', and the
|
|
||||||
// `=` half has nothing left to do.
|
|
||||||
Ok(frag(
|
Ok(frag(
|
||||||
"f.parent LIKE ? ESCAPE '\\'",
|
&format!(
|
||||||
vec![Value::Text(like_subtree_pattern(&base))],
|
"(f.parent = ? COLLATE {} OR f.parent LIKE ? ESCAPE '\\')",
|
||||||
|
crate::platform::PATH_COLLATION
|
||||||
|
),
|
||||||
|
vec![
|
||||||
|
Value::Text(base.clone()),
|
||||||
|
Value::Text(like_subtree_pattern(&base)),
|
||||||
|
],
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
"name" | "filename" => {
|
"name" | "filename" => {
|
||||||
|
|
@ -201,23 +204,21 @@ pub fn escape_like(s: &str) -> String {
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tidy a user-supplied folder value: trim it, and drop any trailing
|
/// Tidy a user-supplied folder value into the spelling `files.parent` stores.
|
||||||
/// separator, of either flavour — that is how people naturally write a
|
|
||||||
/// directory, and either may show up on Windows.
|
|
||||||
///
|
///
|
||||||
/// Empty out means "every folder", which is what a bare `/` or a blank value
|
/// Trailing separators are how people naturally write directories, and either
|
||||||
/// comes to; [`build_filter`] turns that into `1=1`.
|
/// separator may show up on Windows. A bare drive (`C:`) is *not* a path — the
|
||||||
///
|
/// stored parent is `C:\` — so the separator goes back on.
|
||||||
/// It used to special-case a bare drive (`C:` → `C:\`), because the filter's
|
|
||||||
/// `parent = ?` half had to match the stored spelling exactly. That half is
|
|
||||||
/// gone, and [`like_subtree_pattern`] puts the separator back itself, so both
|
|
||||||
/// spellings now produce the same pattern.
|
|
||||||
fn normalize_folder_value(value: &str) -> String {
|
fn normalize_folder_value(value: &str) -> String {
|
||||||
value.trim().trim_end_matches(['/', '\\']).to_string()
|
let base = value.trim().trim_end_matches(['/', '\\']);
|
||||||
|
if base.len() == 2 && base.ends_with(':') && base.starts_with(|c: char| c.is_ascii_alphabetic())
|
||||||
|
{
|
||||||
|
return format!("{}{}", base, std::path::MAIN_SEPARATOR);
|
||||||
|
}
|
||||||
|
base.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `LIKE ... ESCAPE '\'` pattern matching `dir`'s own files and everything
|
/// A `LIKE ... ESCAPE '\'` pattern matching every path strictly beneath `dir`.
|
||||||
/// beneath it.
|
|
||||||
///
|
///
|
||||||
/// The separator is escaped along with the base, because on Windows the
|
/// The separator is escaped along with the base, because on Windows the
|
||||||
/// separator *is* the escape character — a hand-written `format!("{}/%", dir)`
|
/// separator *is* the escape character — a hand-written `format!("{}/%", dir)`
|
||||||
|
|
@ -314,15 +315,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn path_filter_covers_the_folder_and_its_subtree() {
|
fn path_filter_covers_the_folder_and_its_subtree() {
|
||||||
let f = frag("path", Op::Contains, "/home/me/docs");
|
let f = frag("path", Op::Contains, "/home/me/docs");
|
||||||
// One `LIKE`, and one bound value: since every stored parent ends in a
|
assert!(f.sql.contains("f.parent = ?"));
|
||||||
// separator, `dir + SEP + %` reaches the folder's own files as well as
|
assert!(f.sql.contains("f.parent LIKE ?"));
|
||||||
// its subdirectories'. The `parent = ?` half this used to need — and
|
// The LIKE half must declare its escape character; without the clause
|
||||||
// the explicit collation that went with it — is gone.
|
// a Windows separator would be eaten as an escape.
|
||||||
assert!(f.sql.contains("f.parent LIKE ?"), "{}", f.sql);
|
|
||||||
assert!(!f.sql.contains("f.parent = ?"), "{}", f.sql);
|
|
||||||
assert_eq!(f.params.len(), 1);
|
|
||||||
// The LIKE must declare its escape character; without the clause a
|
|
||||||
// Windows separator would be eaten as an escape.
|
|
||||||
assert!(f.sql.contains("ESCAPE '\\'"), "{}", f.sql);
|
assert!(f.sql.contains("ESCAPE '\\'"), "{}", f.sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -408,14 +404,12 @@ mod tests {
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let base = format!("{}a{}b", root_prefix(), SEP);
|
let base = format!("{}a{}b", root_prefix(), SEP);
|
||||||
// Stored parents always end in a separator, so each row here is spelled
|
|
||||||
// the way the indexer would spell it.
|
|
||||||
let rows = [
|
let rows = [
|
||||||
format!("{}{}sub{}", base, SEP, SEP), // inside
|
format!("{}{}sub", base, SEP), // inside
|
||||||
format!("{}{}sub{}deep{}", base, SEP, SEP, SEP), // deeper
|
format!("{}{}sub{}deep", base, SEP, SEP), // deeper
|
||||||
format!("{}{}", base, SEP), // the folder's own files
|
base.clone(), // the folder itself
|
||||||
format!("{}a{}bc{}", root_prefix(), SEP, SEP), // prefix sibling: outside
|
format!("{}a{}bc", root_prefix(), SEP), // prefix sibling: outside
|
||||||
format!("{}a{}", root_prefix(), SEP), // parent: outside
|
format!("{}a", root_prefix()), // parent: outside
|
||||||
];
|
];
|
||||||
for r in &rows {
|
for r in &rows {
|
||||||
conn.execute("INSERT INTO files (parent) VALUES (?1)", [r])
|
conn.execute("INSERT INTO files (parent) VALUES (?1)", [r])
|
||||||
|
|
@ -429,10 +423,7 @@ mod tests {
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Three, not two: the trailing separator is what brings the folder's
|
assert_eq!(matched, 2, "only the two rows strictly beneath {}", base);
|
||||||
// *own* files in, which is why the filter needs no second predicate.
|
|
||||||
// The prefix sibling and the parent stay out.
|
|
||||||
assert_eq!(matched, 3, "the subtree of {}, and only that", base);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -445,9 +436,9 @@ mod tests {
|
||||||
|
|
||||||
let base = format!("{}a_b", root_prefix());
|
let base = format!("{}a_b", root_prefix());
|
||||||
for r in [
|
for r in [
|
||||||
format!("{}{}inside{}", base, SEP, SEP), // real child
|
format!("{}{}inside", base, SEP), // real child
|
||||||
format!("{}axb{}bait{}", root_prefix(), SEP, SEP), // `_` must not glob to `x`
|
format!("{}axb{}bait", root_prefix(), SEP), // `_` must not glob to `x`
|
||||||
format!("{}100%_done{}x{}", root_prefix(), SEP, SEP),
|
format!("{}100%_done{}x", root_prefix(), SEP),
|
||||||
] {
|
] {
|
||||||
conn.execute("INSERT INTO files (parent) VALUES (?1)", [&r])
|
conn.execute("INSERT INTO files (parent) VALUES (?1)", [&r])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
@ -465,17 +456,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn folder_value_normalization() {
|
fn folder_value_normalization() {
|
||||||
|
use std::path::MAIN_SEPARATOR as SEP;
|
||||||
// Trailing separators of either flavour are stripped.
|
// Trailing separators of either flavour are stripped.
|
||||||
assert_eq!(normalize_folder_value("/home/me/"), "/home/me");
|
assert_eq!(normalize_folder_value("/home/me/"), "/home/me");
|
||||||
assert_eq!(normalize_folder_value(r"C:\Users\me\"), r"C:\Users\me");
|
assert_eq!(normalize_folder_value(r"C:\Users\me\"), r"C:\Users\me");
|
||||||
// A bare drive and a rooted one now normalize alike: the pattern
|
// A bare drive is not a path; the stored parent is `C:\`.
|
||||||
// builder puts the separator back either way.
|
assert_eq!(normalize_folder_value("C:"), format!("C:{}", SEP));
|
||||||
assert_eq!(normalize_folder_value("C:"), "C:");
|
assert_eq!(normalize_folder_value(r"C:\"), format!("C:{}", SEP));
|
||||||
assert_eq!(normalize_folder_value(r"C:\"), "C:");
|
|
||||||
assert_eq!(
|
|
||||||
like_subtree_pattern(&normalize_folder_value("C:")),
|
|
||||||
like_subtree_pattern(&normalize_folder_value(r"C:\")),
|
|
||||||
);
|
|
||||||
// Empty means "everywhere".
|
// Empty means "everywhere".
|
||||||
assert_eq!(normalize_folder_value("/"), "");
|
assert_eq!(normalize_folder_value("/"), "");
|
||||||
assert_eq!(normalize_folder_value(" "), "");
|
assert_eq!(normalize_folder_value(" "), "");
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
//! produce. When the configuration changes, the two disagree — and almost
|
//! produce. When the configuration changes, the two disagree — and almost
|
||||||
//! always in a way that can be *reconciled* rather than rebuilt:
|
//! always in a way that can be *reconciled* rather than rebuilt:
|
||||||
//!
|
//!
|
||||||
//! * A root was removed. Its rows are a contiguous `files.parent` range, so
|
//! * A root was removed. Its rows are a contiguous `files.path` range, so
|
||||||
//! they go in four statements ([`crate::db::repo::delete_subtree`]).
|
//! they go in five statements ([`crate::db::repo::delete_subtree`]).
|
||||||
//! * An ignore pattern was added, hidden files were switched off, symlinks
|
//! * An ignore pattern was added, hidden files were switched off, symlinks
|
||||||
//! stopped being followed. The rows to drop are picked out by a predicate
|
//! stopped being followed. The rows to drop are picked out by a predicate
|
||||||
//! no SQL range can express, so [`Scope::covers`] re-runs the walker's own
|
//! no SQL range can express, so [`Scope::covers`] re-runs the walker's own
|
||||||
|
|
@ -51,7 +51,7 @@ use crate::indexing::ReconcileProgress;
|
||||||
/// through [`crate::db::InterruptGuard`].
|
/// through [`crate::db::InterruptGuard`].
|
||||||
pub const SLICE: Duration = Duration::from_millis(250);
|
pub const SLICE: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
/// One configured root, with the `files.parent` range it owns precomputed.
|
/// One configured root, with the `files.path` range it owns precomputed.
|
||||||
struct Root {
|
struct Root {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
lo: String,
|
lo: String,
|
||||||
|
|
@ -208,9 +208,9 @@ pub struct WorkCursor {
|
||||||
dropped_aliases: bool,
|
dropped_aliases: bool,
|
||||||
/// Index into `scope.roots` of the range being scanned.
|
/// Index into `scope.roots` of the range being scanned.
|
||||||
root_idx: usize,
|
root_idx: usize,
|
||||||
/// Last `(parent, name)` served by the scan — the keyset cursor. An empty
|
/// Last path served by the scan — the keyset cursor. Empty means "start
|
||||||
/// parent means "start this root's range from its `lo` bound".
|
/// this root's range from its `lo` bound".
|
||||||
after: (String, String),
|
after: String,
|
||||||
/// Set once the FTS automerge that follows a batch of deletions has run.
|
/// Set once the FTS automerge that follows a batch of deletions has run.
|
||||||
finalized: bool,
|
finalized: bool,
|
||||||
/// Rows deleted so far, for the log line when the work completes.
|
/// Rows deleted so far, for the log line when the work completes.
|
||||||
|
|
@ -233,7 +233,7 @@ impl WorkCursor {
|
||||||
drop_idx: 0,
|
drop_idx: 0,
|
||||||
dropped_aliases: false,
|
dropped_aliases: false,
|
||||||
root_idx: 0,
|
root_idx: 0,
|
||||||
after: (String::new(), String::new()),
|
after: String::new(),
|
||||||
finalized: false,
|
finalized: false,
|
||||||
deleted: 0,
|
deleted: 0,
|
||||||
recontented: 0,
|
recontented: 0,
|
||||||
|
|
@ -352,19 +352,16 @@ pub fn advance(
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let root = &cursor.scope.roots[cursor.root_idx];
|
let root = &cursor.scope.roots[cursor.root_idx];
|
||||||
if cursor.after.0.is_empty() {
|
if cursor.after.is_empty() {
|
||||||
// `(lo, "")` sorts below every row in the range: no stored name
|
cursor.after = root.lo.clone();
|
||||||
// is empty, so the first page starts exactly at `lo`.
|
|
||||||
cursor.after = (root.lo.clone(), String::new());
|
|
||||||
}
|
}
|
||||||
let rows =
|
let rows = repo::rows_in_range_page(conn, &cursor.after, &root.hi, page)?;
|
||||||
repo::rows_in_range_page(conn, &cursor.after.0, &cursor.after.1, &root.hi, page)?;
|
|
||||||
let Some(last) = rows.last() else {
|
let Some(last) = rows.last() else {
|
||||||
cursor.root_idx += 1;
|
cursor.root_idx += 1;
|
||||||
cursor.after = (String::new(), String::new());
|
cursor.after.clear();
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
cursor.after = (last.parent.clone(), last.name.clone());
|
cursor.after = last.path.clone();
|
||||||
cursor.examined += rows.len();
|
cursor.examined += rows.len();
|
||||||
let root = cursor.scope.roots[cursor.root_idx].path.clone();
|
let root = cursor.scope.roots[cursor.root_idx].path.clone();
|
||||||
let (deleted, recontented) = apply_page(
|
let (deleted, recontented) = apply_page(
|
||||||
|
|
|
||||||
|
|
@ -45,12 +45,12 @@ fn seed(conn: &mut Connection, paths: &[PathBuf]) {
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
for path in paths {
|
for path in paths {
|
||||||
let path = path.to_string_lossy();
|
let path = path.to_string_lossy();
|
||||||
// The indexer's own split, so the separator stays with the parent.
|
let (parent, name) = path.rsplit_once('/').unwrap();
|
||||||
let (parent, name) = crate::file_handling::split_db_path(&path).expect("a file's path");
|
|
||||||
repo::insert_file(
|
repo::insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&repo::NewFile {
|
&repo::NewFile {
|
||||||
name,
|
name,
|
||||||
|
path: &path,
|
||||||
parent,
|
parent,
|
||||||
size: 1,
|
size: 1,
|
||||||
mtime: 1,
|
mtime: 1,
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@
|
||||||
//! order.
|
//! order.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::hash::{BuildHasherDefault, Hasher};
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
|
@ -68,7 +67,7 @@ use crate::query::split::CascadeQuery;
|
||||||
use crate::query::translator::{escape_like, quote_phrase};
|
use crate::query::translator::{escape_like, quote_phrase};
|
||||||
use crate::snippet;
|
use crate::snippet;
|
||||||
|
|
||||||
use super::fuzzy::{edit_budget, pigeonhole_chunks, Bitap};
|
use super::fuzzy::{edit_budget, Bitap};
|
||||||
use super::{SearchHit, SearchOptions};
|
use super::{SearchHit, SearchOptions};
|
||||||
|
|
||||||
mod passes;
|
mod passes;
|
||||||
|
|
@ -101,10 +100,10 @@ pub fn text_snippet(
|
||||||
let opts = snippet::Options {
|
let opts = snippet::Options {
|
||||||
approx_chars: SNIPPET_WINDOW_CHARS,
|
approx_chars: SNIPPET_WINDOW_CHARS,
|
||||||
};
|
};
|
||||||
match text_snippet_counted(pattern, text, folded) {
|
match pattern.literal() {
|
||||||
// Literal terms keep the richer multi-occurrence extract; a wildcard
|
// Literal terms keep the richer multi-occurrence extract; a wildcard
|
||||||
// match marks its own first range.
|
// match marks its own first range.
|
||||||
Some((snip, _)) => Some(snip),
|
Some(term) => Some(snippet::extract_folded(text, folded, &[term], &opts)),
|
||||||
None => pattern.find_first_folded(folded).map(|r| {
|
None => pattern.find_first_folded(folded).map(|r| {
|
||||||
// A greedy pattern can match megabytes; clamp before the window.
|
// A greedy pattern can match megabytes; clamp before the window.
|
||||||
let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS);
|
let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS);
|
||||||
|
|
@ -113,31 +112,6 @@ pub fn text_snippet(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`text_snippet`] for a literal pattern, with the case-insensitive
|
|
||||||
/// occurrence count the extraction found on its way to the window. `None`
|
|
||||||
/// when the pattern is not literal.
|
|
||||||
///
|
|
||||||
/// The count is a by-product: [`snippet::extract_folded`] locates every
|
|
||||||
/// occurrence in `folded` in order to coalesce and mark them, and that set has
|
|
||||||
/// the same cardinality [`crate::query::pattern::TermPattern::count_folded`]
|
|
||||||
/// would return. Taking it from here is what lets the full-text pass verify a
|
|
||||||
/// row and cut its snippet in one sweep of the body rather than two — see
|
|
||||||
/// `benches/search.rs`, group `cascade_row_sweeps`. It does not generalise to
|
|
||||||
/// a wildcard, whose snippet comes from a single leftmost match.
|
|
||||||
pub fn text_snippet_counted(
|
|
||||||
pattern: &crate::query::pattern::TermPattern,
|
|
||||||
text: &str,
|
|
||||||
folded: &str,
|
|
||||||
) -> Option<(snippet::Snippet, usize)> {
|
|
||||||
let opts = snippet::Options {
|
|
||||||
approx_chars: SNIPPET_WINDOW_CHARS,
|
|
||||||
};
|
|
||||||
// The pre-folded form: this runs per candidate row, and folding the term
|
|
||||||
// again here would allocate once per row for a string the pattern holds.
|
|
||||||
let term = pattern.literal_folded()?;
|
|
||||||
Some(snippet::extract_folded(text, folded, &[term], &opts))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fuzzy full-text match in one document body: how many times the term
|
/// The fuzzy full-text match in one document body: how many times the term
|
||||||
/// occurs within the edit budget, and the Content Match window cut around
|
/// occurs within the edit budget, and the Content Match window cut around
|
||||||
/// the first occurrence at the cascade's own width. `None` when it does not
|
/// the first occurrence at the cascade's own width. `None` when it does not
|
||||||
|
|
@ -146,23 +120,19 @@ pub fn text_snippet_counted(
|
||||||
/// Shared with [`crate::live`] for the same reason as [`text_snippet`]: a
|
/// Shared with [`crate::live`] for the same reason as [`text_snippet`]: a
|
||||||
/// fuzzy row whose file changes has to be re-cut the way it was cut, and
|
/// fuzzy row whose file changes has to be re-cut the way it was cut, and
|
||||||
/// bitap's range is what it was cut around. `bitap` is built once by the
|
/// bitap's range is what it was cut around. `bitap` is built once by the
|
||||||
/// caller — per scan in the pass, per arm in the live watcher — since building
|
/// caller — per scan in the pass, per arm in the live watcher — since
|
||||||
/// it is the cost.
|
/// building it is the cost, and `folded` must be `text` ASCII-lowercased.
|
||||||
///
|
|
||||||
/// Takes no folded copy, unlike [`text_snippet`]: the matcher folds in its
|
|
||||||
/// mask table (see [`crate::search::fuzzy`]), so it reads the body as stored.
|
|
||||||
/// That is the difference between this pass copying and lowercasing every
|
|
||||||
/// document in the index per keystroke and not doing so.
|
|
||||||
pub fn fuzzy_snippet(
|
pub fn fuzzy_snippet(
|
||||||
bitap: &crate::search::fuzzy::Bitap,
|
bitap: &crate::search::fuzzy::Bitap,
|
||||||
text: &str,
|
text: &str,
|
||||||
|
folded: &str,
|
||||||
) -> Option<(usize, snippet::Snippet)> {
|
) -> Option<(usize, snippet::Snippet)> {
|
||||||
let opts = snippet::Options {
|
let opts = snippet::Options {
|
||||||
approx_chars: SNIPPET_WINDOW_CHARS,
|
approx_chars: SNIPPET_WINDOW_CHARS,
|
||||||
};
|
};
|
||||||
// `first` is `Some` exactly when `count` is non-zero: it *is* the first
|
// `first` is `Some` exactly when `count` is non-zero: it *is* the first
|
||||||
// of them.
|
// of them.
|
||||||
let (count, first) = bitap.count_and_first(text.as_bytes());
|
let (count, first) = bitap.count_and_first(folded.as_bytes());
|
||||||
first.map(|range| (count, snippet::window_around(text, range, &opts)))
|
first.map(|range| (count, snippet::window_around(text, range, &opts)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,7 +171,7 @@ pub fn run(
|
||||||
generation,
|
generation,
|
||||||
latest_gen,
|
latest_gen,
|
||||||
ignore,
|
ignore,
|
||||||
emitted: IdSet::default(),
|
emitted: HashSet::new(),
|
||||||
deferred_path: Deferred::default(),
|
deferred_path: Deferred::default(),
|
||||||
deferred_fuzzy_path: Deferred::default(),
|
deferred_fuzzy_path: Deferred::default(),
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|
@ -227,13 +197,8 @@ pub fn run(
|
||||||
if cx.cancelled() {
|
if cx.cancelled() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Stop, but do not call it truncation. `remaining() == 0` is also
|
|
||||||
// what an exactly-full result set looks like, and claiming a cut there
|
|
||||||
// tells a user to raise `--limit` on a complete answer. `flush_pass`
|
|
||||||
// is the authority — it sets the flag when it actually had to drop
|
|
||||||
// rows — and `scan_pass` breaks its row loop the moment the limit
|
|
||||||
// fills, for the same reason this break exists.
|
|
||||||
if cx.remaining() == 0 {
|
if cx.remaining() == 0 {
|
||||||
|
cx.limited = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let run_pass = match pass {
|
let run_pass = match pass {
|
||||||
|
|
@ -312,14 +277,11 @@ fn rank_order(a: &SearchHit, b: &SearchHit) -> std::cmp::Ordering {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `files` columns every pass selects, in the order the passes index
|
/// The `files` columns every pass selects, in the order the passes index
|
||||||
/// them: `0` id, `1` name, `2` parent, `3` size, `4` mtime. Passes that also
|
/// them: `0` id, `1` name, `2` path, `3` size, `4` mtime. Passes that also
|
||||||
/// want the stored document text append `dt.text_zstd` as column `5`. A pass
|
/// want the stored document text append `dt.text_zstd` as column `5`. A pass
|
||||||
/// spelling its own list in a different order would compile and then quietly
|
/// spelling its own list in a different order would compile and then quietly
|
||||||
/// serve parents as names.
|
/// serve paths as names.
|
||||||
///
|
const HIT_COLUMNS: &str = "f.id, f.name, f.path, f.size, f.mtime";
|
||||||
/// There is no `path` column to select; [`Cx::scan_pass`] concatenates columns
|
|
||||||
/// 2 and 1 into one reused buffer and hands every classifier the result.
|
|
||||||
const HIT_COLUMNS: &str = "f.id, f.name, f.parent, f.size, f.mtime";
|
|
||||||
|
|
||||||
/// Columns 3 and 4. The clamp matters: `size` is `INTEGER` in SQLite and so
|
/// Columns 3 and 4. The clamp matters: `size` is `INTEGER` in SQLite and so
|
||||||
/// signed; a corrupt row holding `-1` would otherwise become 18 exabytes on
|
/// signed; a corrupt row holding `-1` would otherwise become 18 exabytes on
|
||||||
|
|
@ -352,54 +314,6 @@ struct Deferred {
|
||||||
/// inside the GUI's 250 ms result fade.
|
/// inside the GUI's 250 ms result fade.
|
||||||
const FLUSH_INTERVAL: Duration = Duration::from_millis(80);
|
const FLUSH_INTERVAL: Duration = Duration::from_millis(80);
|
||||||
|
|
||||||
/// Hasher for the emitted-id set, which is probed **once per scanned row**.
|
|
||||||
///
|
|
||||||
/// The default `HashSet` hasher is SipHash-1-3, chosen to be collision-resistant
|
|
||||||
/// against hostile keys. These keys are SQLite rowids the cascade itself just
|
|
||||||
/// read — nobody outside chooses them — so the resistance buys nothing, while
|
|
||||||
/// the ~10 ns it costs is paid on every row of a whole-table scan.
|
|
||||||
///
|
|
||||||
/// Multiplicative, by an odd constant near 2^64/φ. Odd keeps it a bijection, so
|
|
||||||
/// distinct ids stay distinct; hashbrown then takes the low bits for the bucket
|
|
||||||
/// and the top seven for its control byte, and the rotate is what puts real
|
|
||||||
/// entropy in both halves for the near-consecutive ids a table scan produces.
|
|
||||||
///
|
|
||||||
/// Worth ~5% of a fuzzy search — 17.1/17.4/17.9 ms against 18.2/18.3/18.8 over
|
|
||||||
/// three runs each of `tests/search_alloc.rs`, every run of the one beating
|
|
||||||
/// every run of the other. Small, but it is the whole of what a hash function
|
|
||||||
/// choice can be worth, and it is bought for fifteen lines with no behaviour
|
|
||||||
/// attached. The figure will grow with the row count: this is one probe per
|
|
||||||
/// scanned row, and the corpus is 50,000 rows.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct IdHasher(u64);
|
|
||||||
|
|
||||||
impl Hasher for IdHasher {
|
|
||||||
fn finish(&self) -> u64 {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// The only shape the set ever hashes is a single `i64`, which `Hash for
|
|
||||||
// i64` delivers through `write_i64`. `write` exists because the trait
|
|
||||||
// requires it and is deliberately a poor general-purpose hash: routing
|
|
||||||
// anything else through here would be a bug, not a use.
|
|
||||||
fn write(&mut self, bytes: &[u8]) {
|
|
||||||
for &b in bytes {
|
|
||||||
self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_i64(&mut self, n: i64) {
|
|
||||||
self.write_u64(n as u64);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_u64(&mut self, n: u64) {
|
|
||||||
let mixed = n.wrapping_mul(0x9E37_79B9_7F4A_7C15);
|
|
||||||
self.0 = mixed.rotate_left(31) ^ mixed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type IdSet = HashSet<i64, BuildHasherDefault<IdHasher>>;
|
|
||||||
|
|
||||||
struct Cx<'a> {
|
struct Cx<'a> {
|
||||||
conn: &'a Connection,
|
conn: &'a Connection,
|
||||||
query: &'a CascadeQuery,
|
query: &'a CascadeQuery,
|
||||||
|
|
@ -407,7 +321,7 @@ struct Cx<'a> {
|
||||||
generation: u64,
|
generation: u64,
|
||||||
latest_gen: &'a AtomicU64,
|
latest_gen: &'a AtomicU64,
|
||||||
ignore: IgnoreSet,
|
ignore: IgnoreSet,
|
||||||
emitted: IdSet,
|
emitted: HashSet<i64>,
|
||||||
/// Ranks 9–10, filled by pass A.
|
/// Ranks 9–10, filled by pass A.
|
||||||
deferred_path: Deferred,
|
deferred_path: Deferred,
|
||||||
/// Rank 11, filled by pass C.
|
/// Rank 11, filled by pass C.
|
||||||
|
|
|
||||||
|
|
@ -20,36 +20,6 @@ fn fold_into(dst: &mut String, text: &str) {
|
||||||
dst.make_ascii_lowercase();
|
dst.make_ascii_lowercase();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The segment a straddling wildcard can be SQL-prefiltered on: the longest
|
|
||||||
/// one containing no path separator, or `None` when every segment has one.
|
|
||||||
///
|
|
||||||
/// # Why one segment is enough, and why it must be separator-free
|
|
||||||
///
|
|
||||||
/// The classifier accepts a row only if the whole pattern matches `name` or
|
|
||||||
/// `parent || name`, and either way **every** segment occurs contiguously
|
|
||||||
/// somewhere in `parent || name`. So testing any single segment is a superset
|
|
||||||
/// of what the classifier accepts — the filter can only be too generous, never
|
|
||||||
/// too strict, which is the direction that keeps real hits from vanishing.
|
|
||||||
///
|
|
||||||
/// Splitting that test across the two stored columns is what needs the
|
|
||||||
/// separator-free condition. An occurrence of a segment in `parent || name`
|
|
||||||
/// lies wholly inside `parent`, wholly inside `name`, or spans the join. A
|
|
||||||
/// spanning occurrence covers the byte immediately before the boundary, and
|
|
||||||
/// that byte is `parent`'s last — which is always a separator, by the
|
|
||||||
/// invariant `file_handling::dir_to_db_parent` maintains. A segment with no
|
|
||||||
/// separator in it therefore cannot span the join, so
|
|
||||||
/// `name LIKE %s% OR parent LIKE %s%` sees it wherever it is.
|
|
||||||
///
|
|
||||||
/// The longest is chosen for selectivity alone; any of them would be correct.
|
|
||||||
fn anchor_segment(pattern: &crate::query::pattern::TermPattern) -> Option<&str> {
|
|
||||||
pattern
|
|
||||||
.segments()
|
|
||||||
.iter()
|
|
||||||
.filter(|s| !s.contains(std::path::MAIN_SEPARATOR))
|
|
||||||
.max_by_key(|s| s.len())
|
|
||||||
.map(String::as_str)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which [`Deferred`] buffer a scan's held-back hits go to.
|
/// Which [`Deferred`] buffer a scan's held-back hits go to.
|
||||||
enum DeferSlot {
|
enum DeferSlot {
|
||||||
/// Ranks 9–10, flushed by [`Pass::Path`]. Shared by passes A and E,
|
/// Ranks 9–10, flushed by [`Pass::Path`]. Shared by passes A and E,
|
||||||
|
|
@ -70,28 +40,13 @@ impl<'a> Cx<'a> {
|
||||||
/// `Defer`red hit lands in `defer_slot` at the end of the scan rather
|
/// `Defer`red hit lands in `defer_slot` at the end of the scan rather
|
||||||
/// than being emitted — path-tier ranks sort below stages that have not
|
/// than being emitted — path-tier ranks sort below stages that have not
|
||||||
/// run yet, so they are held back and never flushed mid-scan.
|
/// run yet, so they are held back and never flushed mid-scan.
|
||||||
///
|
|
||||||
/// `classify` receives the reassembled path **and the name as a slice of
|
|
||||||
/// it**, both borrowed. The name is not fetched a second time from the row:
|
|
||||||
/// a path is `parent || name`, so the name is already sitting in the tail
|
|
||||||
/// of the buffer this pass just built. Handing it over borrowed is what
|
|
||||||
/// keeps a whole-table scan from allocating a `String` per *scanned* row
|
|
||||||
/// when only the few that become hits need an owned copy — measured at one
|
|
||||||
/// allocation per row for the passes with no SQL prefilter (wildcard,
|
|
||||||
/// regex) and three per row for the fuzzy filename pass.
|
|
||||||
fn scan_pass(
|
fn scan_pass(
|
||||||
&mut self,
|
&mut self,
|
||||||
sql: &str,
|
sql: &str,
|
||||||
params: Vec<rusqlite::types::Value>,
|
params: Vec<rusqlite::types::Value>,
|
||||||
cancel_every: usize,
|
cancel_every: usize,
|
||||||
defer_slot: Option<DeferSlot>,
|
defer_slot: Option<DeferSlot>,
|
||||||
mut classify: impl FnMut(
|
mut classify: impl FnMut(&mut Self, &rusqlite::Row<'_>, i64, &str) -> Result<RowHit, String>,
|
||||||
&mut Self,
|
|
||||||
&rusqlite::Row<'_>,
|
|
||||||
i64,
|
|
||||||
&str,
|
|
||||||
&str,
|
|
||||||
) -> Result<RowHit, String>,
|
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let conn = self.conn;
|
let conn = self.conn;
|
||||||
// Cached: a search re-runs the same six statements on every keystroke,
|
// Cached: a search re-runs the same six statements on every keystroke,
|
||||||
|
|
@ -107,77 +62,41 @@ impl<'a> Cx<'a> {
|
||||||
let mut deferred = Deferred::default();
|
let mut deferred = Deferred::default();
|
||||||
let mut scanned = 0usize;
|
let mut scanned = 0usize;
|
||||||
let mut clock = FlushClock::new();
|
let mut clock = FlushClock::new();
|
||||||
// Set when the loop stops on the display limit rather than on the end
|
|
||||||
// of the candidate set — rows were left unexamined, so the result set
|
|
||||||
// is cut whatever the final flush happens to hold.
|
|
||||||
let mut cut_short = false;
|
|
||||||
// The reassembled path for the row in hand. One buffer for the whole
|
|
||||||
// scan, refilled per row: `files` stores `parent` and `name` and no
|
|
||||||
// longer a third column holding their concatenation, so this is where
|
|
||||||
// the concatenation happens. Reused rather than allocated because it
|
|
||||||
// runs for every *scanned* row — a full-table scan on three of the
|
|
||||||
// passes — while only the few that become hits keep an owned copy.
|
|
||||||
//
|
|
||||||
// A local, not a field on `self`: the classifiers take `&mut Self`, and
|
|
||||||
// a buffer borrowed out of `self` could not be handed to them.
|
|
||||||
let mut path = String::new();
|
|
||||||
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
while let Some(row) = rows.next().map_err(|e| e.to_string())? {
|
||||||
scanned += 1;
|
scanned += 1;
|
||||||
if scanned.is_multiple_of(cancel_every) && self.cancelled() {
|
if scanned.is_multiple_of(cancel_every) && self.cancelled() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
// The display limit is already full, and holding a row proves at
|
||||||
|
// least one more match exists than will be shown — so `limited` is
|
||||||
|
// exactly true here, and everything below is work whose result
|
||||||
|
// `flush_pass` would throw away. That work is not small: the
|
||||||
|
// full-text passes decompress the document, fold a copy of it, and
|
||||||
|
// cut a snippet, per row. `cascade::run` makes the same test
|
||||||
|
// between passes; without this one a single pass over a common
|
||||||
|
// term runs to the end of the candidate set.
|
||||||
|
if self.remaining() == 0 {
|
||||||
|
self.limited = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
let file_id: i64 = col(row, 0)?;
|
let file_id: i64 = col(row, 0)?;
|
||||||
let borrowed = |idx| -> Result<&str, String> {
|
// Borrowed from the statement rather than `col::<String>`: this
|
||||||
row.get_ref(idx)
|
// runs for every *scanned* row — a full-table scan on the filename
|
||||||
.map_err(|e| e.to_string())?
|
// pass — while only the few that become hits need an owned copy.
|
||||||
.as_str()
|
let path = row
|
||||||
.map_err(|e| e.to_string())
|
.get_ref(2)
|
||||||
};
|
.map_err(|e| e.to_string())?
|
||||||
path.clear();
|
.as_str()
|
||||||
// Parent first, and no separator between them: every stored parent
|
.map_err(|e| e.to_string())?;
|
||||||
// already ends in one. See `file_handling::dir_to_db_parent`.
|
if self.skip(file_id, path) {
|
||||||
path.push_str(borrowed(2)?);
|
|
||||||
// Where the name starts, so the classifier can have it as a slice
|
|
||||||
// of the path rather than as a second fetch and a second
|
|
||||||
// allocation. The parent is written whole and nothing rewrites it,
|
|
||||||
// so this offset stays a char boundary.
|
|
||||||
let name_at = path.len();
|
|
||||||
path.push_str(borrowed(1)?);
|
|
||||||
if self.skip(file_id, &path) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match classify(self, row, file_id, &path, &path[name_at..])? {
|
match classify(self, row, file_id, path)? {
|
||||||
RowHit::Skip => {}
|
RowHit::Skip => {}
|
||||||
RowHit::Emit(hit) => {
|
RowHit::Emit(hit) => {
|
||||||
buf.push(hit);
|
buf.push(hit);
|
||||||
overflowed |= self.enforce_cap(&mut buf);
|
overflowed |= self.enforce_cap(&mut buf);
|
||||||
self.flush_if_due(&mut buf, &mut clock);
|
self.flush_if_due(&mut buf, &mut clock);
|
||||||
// Stop once the display limit is full. Everything past
|
|
||||||
// this point is work `flush_pass` would throw away, and it
|
|
||||||
// is not small: the full-text passes decompress the
|
|
||||||
// document, fold a copy of it and cut a snippet, per row.
|
|
||||||
// `cascade::run` makes the same test between passes;
|
|
||||||
// without this one a single pass over a common term runs
|
|
||||||
// to the end of its candidate set.
|
|
||||||
//
|
|
||||||
// Tested *here* rather than at the top of the loop, where
|
|
||||||
// it is tempting: a scanned row is not a match. It may be
|
|
||||||
// one an earlier pass already emitted, or one `classify`
|
|
||||||
// rejects — the filename pass's SQL is deliberately a
|
|
||||||
// superset feeding both the name and the path tier — so
|
|
||||||
// stopping on one would end the pass over a row that was
|
|
||||||
// never going to be shown.
|
|
||||||
//
|
|
||||||
// The break is recorded rather than turned into `limited`
|
|
||||||
// on the spot. `flush_pass` decides that, because it knows
|
|
||||||
// how much it had to truncate; claiming it here would say
|
|
||||||
// a set was cut whenever it happened to end exactly at the
|
|
||||||
// limit. But leaving the scan *unfinished* silently is the
|
|
||||||
// opposite error — see the flush below.
|
|
||||||
if self.remaining() == 0 {
|
|
||||||
cut_short = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
RowHit::Defer(hit) => {
|
RowHit::Defer(hit) => {
|
||||||
deferred.hits.push(hit);
|
deferred.hits.push(hit);
|
||||||
|
|
@ -195,15 +114,6 @@ impl<'a> Cx<'a> {
|
||||||
None => debug_assert!(deferred.hits.is_empty(), "deferred hits with no slot"),
|
None => debug_assert!(deferred.hits.is_empty(), "deferred hits with no slot"),
|
||||||
}
|
}
|
||||||
self.flush_pass(buf, overflowed);
|
self.flush_pass(buf, overflowed);
|
||||||
// After the flush, so it cannot be undone by one that truncated
|
|
||||||
// nothing. A scan that broke on the limit left rows unexamined, and
|
|
||||||
// the two places that would otherwise notice both miss the boundary
|
|
||||||
// case: `flush_pass` compares `buf.len() > room`, which is `0 > 0`
|
|
||||||
// when a mid-scan flush landed exactly on the limit, and
|
|
||||||
// `cascade::run` tests `remaining()` at the top of the *next* pass,
|
|
||||||
// of which the last pass has none. Without this a cut result set can
|
|
||||||
// report itself complete.
|
|
||||||
self.limited |= cut_short;
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,91 +123,43 @@ impl<'a> Cx<'a> {
|
||||||
let query = self.query;
|
let query = self.query;
|
||||||
let pattern = &query.pattern;
|
let pattern = &query.pattern;
|
||||||
let with_paths = path_tiers_enabled(pattern);
|
let with_paths = path_tiers_enabled(pattern);
|
||||||
|
// A path always ends in its own name, so `path LIKE` is the
|
||||||
|
// superset that feeds both the name and the path tiers.
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {} FROM files f \
|
||||||
|
WHERE {} LIKE ? ESCAPE '\\'{}",
|
||||||
|
HIT_COLUMNS,
|
||||||
|
if with_paths { "f.path" } else { "f.name" },
|
||||||
|
query.filter_sql
|
||||||
|
);
|
||||||
// Wildcard patterns turn each star into an unescaped `%`; the
|
// Wildcard patterns turn each star into an unescaped `%`; the
|
||||||
// substring wrap absorbs leading/trailing stars. User `%`/`_`
|
// substring wrap absorbs leading/trailing stars. User `%`/`_`
|
||||||
// remain escaped literals either way.
|
// remain escaped literals either way.
|
||||||
let like = format!(
|
let like = pattern
|
||||||
"%{}%",
|
.segments()
|
||||||
pattern
|
.iter()
|
||||||
.segments()
|
.map(|s| escape_like(s))
|
||||||
.iter()
|
.collect::<Vec<_>>()
|
||||||
.map(|s| escape_like(s))
|
.join("%");
|
||||||
.collect::<Vec<_>>()
|
let params =
|
||||||
.join("%")
|
self.params_with_filters(vec![rusqlite::types::Value::Text(format!("%{}%", like))]);
|
||||||
);
|
|
||||||
// There is no `path` column to `LIKE` against any more, and the
|
|
||||||
// prefilter has to stay a *superset* of what the classifier accepts or
|
|
||||||
// real hits vanish. Three cases:
|
|
||||||
//
|
|
||||||
// * Name tier only — unchanged, `name LIKE`.
|
|
||||||
// * Path tiers, and the pattern is a single segment containing no
|
|
||||||
// separator: `name LIKE ? OR parent LIKE ?` is then exactly
|
|
||||||
// equivalent to the old `path LIKE ?`. A match inside `parent || name`
|
|
||||||
// either sits wholly in one or straddles the boundary, and the
|
|
||||||
// boundary character is a separator the pattern does not contain. This
|
|
||||||
// is ordinary typing, so it is the case worth keeping cheap.
|
|
||||||
// * A multi-segment wildcard whose `%` can span the boundary
|
|
||||||
// (`doc*q3` over `/x/docs/q3.txt`), or a term with a separator in it.
|
|
||||||
// The whole *pattern* cannot be pinned to one column, but one of its
|
|
||||||
// segments can — see [`anchor_segment`] — and that is enough for a
|
|
||||||
// superset. Only when no segment qualifies is there nothing left to
|
|
||||||
// filter on and the pass scans, as passes C and E do.
|
|
||||||
//
|
|
||||||
// That last case is not a rare one, which is why it is worth the
|
|
||||||
// argument: `rep*rt` is two segments, so ordinary wildcard typing used
|
|
||||||
// to land on `1=1` and pay four regex evaluations — anchored and
|
|
||||||
// unanchored, cased and folded — on every row in the index.
|
|
||||||
let straddles = pattern.segments().len() > 1
|
|
||||||
|| pattern
|
|
||||||
.segments()
|
|
||||||
.iter()
|
|
||||||
.any(|s| s.contains(std::path::MAIN_SEPARATOR));
|
|
||||||
// Both columns, one bound value: `like_for` builds it once and it is
|
|
||||||
// bound twice.
|
|
||||||
let two_column = || {
|
|
||||||
"(f.name LIKE ? ESCAPE '\\' OR f.parent LIKE ? ESCAPE '\\')".to_string()
|
|
||||||
};
|
|
||||||
let bind_twice = |pat: String| {
|
|
||||||
vec![
|
|
||||||
rusqlite::types::Value::Text(pat.clone()),
|
|
||||||
rusqlite::types::Value::Text(pat),
|
|
||||||
]
|
|
||||||
};
|
|
||||||
let (predicate, terms) = match (with_paths, straddles) {
|
|
||||||
(false, _) => (
|
|
||||||
"f.name LIKE ? ESCAPE '\\'".to_string(),
|
|
||||||
vec![rusqlite::types::Value::Text(like)],
|
|
||||||
),
|
|
||||||
(true, false) => (two_column(), bind_twice(like)),
|
|
||||||
(true, true) => match anchor_segment(pattern) {
|
|
||||||
Some(anchor) => (
|
|
||||||
two_column(),
|
|
||||||
bind_twice(format!("%{}%", escape_like(anchor))),
|
|
||||||
),
|
|
||||||
None => ("1=1".to_string(), Vec::new()),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let sql = format!(
|
|
||||||
"SELECT {} FROM files f WHERE {}{}",
|
|
||||||
HIT_COLUMNS, predicate, query.filter_sql
|
|
||||||
);
|
|
||||||
let params = self.params_with_filters(terms);
|
|
||||||
self.scan_pass(
|
self.scan_pass(
|
||||||
&sql,
|
&sql,
|
||||||
params,
|
params,
|
||||||
CANCEL_CHECK_ROWS,
|
CANCEL_CHECK_ROWS,
|
||||||
Some(DeferSlot::Path),
|
Some(DeferSlot::Path),
|
||||||
|cx, row, file_id, path, name| {
|
|cx, row, file_id, path| {
|
||||||
|
let name: String = col(row, 1)?;
|
||||||
// Folding is byte-length preserving, so folded offsets are
|
// Folding is byte-length preserving, so folded offsets are
|
||||||
// valid in the original. For wildcards, tiers 1/2 mean "the
|
// valid in the original. For wildcards, tiers 1/2 mean "the
|
||||||
// whole name matches the pattern".
|
// whole name matches the pattern".
|
||||||
let (rank, match_range) = if pattern.whole_match(name, false) {
|
let (rank, match_range) = if pattern.whole_match(&name, false) {
|
||||||
(1.0, (0, name.len()))
|
(1.0, (0, name.len()))
|
||||||
} else if pattern.whole_match(name, true) {
|
} else if pattern.whole_match(&name, true) {
|
||||||
(2.0, (0, name.len()))
|
(2.0, (0, name.len()))
|
||||||
} else if let Some(r) = pattern.find_first(name, false) {
|
} else if let Some(r) = pattern.find_first(&name, false) {
|
||||||
(3.0, (r.start, r.end))
|
(3.0, (r.start, r.end))
|
||||||
} else if let Some(r) = pattern.find_first(name, true) {
|
} else if let Some(r) = pattern.find_first(&name, true) {
|
||||||
(4.0, (r.start, r.end))
|
(4.0, (r.start, r.end))
|
||||||
} else if !with_paths {
|
} else if !with_paths {
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
|
|
@ -316,12 +178,14 @@ impl<'a> Cx<'a> {
|
||||||
let is_path_tier = rank >= 9.0;
|
let is_path_tier = rank >= 9.0;
|
||||||
// The "snippet" of a name or path hit is that field itself
|
// The "snippet" of a name or path hit is that field itself
|
||||||
// with the matched span marked.
|
// with the matched span marked.
|
||||||
let snip =
|
let snip = snippet::whole_field(
|
||||||
snippet::whole_field(if is_path_tier { path } else { name }, match_range);
|
if is_path_tier { path } else { name.as_str() },
|
||||||
|
match_range,
|
||||||
|
);
|
||||||
let (size, mtime) = size_and_mtime(row)?;
|
let (size, mtime) = size_and_mtime(row)?;
|
||||||
let hit = SearchHit {
|
let hit = SearchHit {
|
||||||
file_id,
|
file_id,
|
||||||
name: name.to_string(),
|
name,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
@ -398,7 +262,7 @@ impl<'a> Cx<'a> {
|
||||||
let mut doc = crate::db::repo::DocDecoder::new()?;
|
let mut doc = crate::db::repo::DocDecoder::new()?;
|
||||||
let mut lower = String::new();
|
let mut lower = String::new();
|
||||||
// Decompression dominates: check cancellation every row.
|
// Decompression dominates: check cancellation every row.
|
||||||
self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path, name| {
|
self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path| {
|
||||||
let blob: Option<&[u8]> = row
|
let blob: Option<&[u8]> = row
|
||||||
.get_ref(5)
|
.get_ref(5)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
|
|
@ -410,48 +274,30 @@ impl<'a> Cx<'a> {
|
||||||
Some(text) => {
|
Some(text) => {
|
||||||
// Fold once: the case-insensitive count, the first-match
|
// Fold once: the case-insensitive count, the first-match
|
||||||
// search and the snippet extraction all need it, and
|
// search and the snippet extraction all need it, and
|
||||||
// nearly every candidate takes this path — including
|
// nearly every candidate takes this path.
|
||||||
// every candidate that is about to be dropped, since
|
let mut folded = false;
|
||||||
// proving the pattern absent is itself a folded scan.
|
let (count, stage) = {
|
||||||
fold_into(&mut lower, text);
|
let count_cs = pattern.count(text, false);
|
||||||
match super::text_snippet_counted(pattern, text, &lower) {
|
if count_cs > 0 {
|
||||||
// Literal: one folded sweep does the verifying, the
|
(count_cs, 5)
|
||||||
// counting and the snippet together. The
|
} else {
|
||||||
// case-sensitive count is still its own sweep, but
|
fold_into(&mut lower, text);
|
||||||
// only for a row that survived — it decides stage 5
|
folded = true;
|
||||||
// against stage 6, and ranks within 5.
|
let count_ci = pattern.count_folded(&lower);
|
||||||
Some((snip, count_ci)) => {
|
if count_ci > 0 {
|
||||||
if count_ci == 0 {
|
(count_ci, 6)
|
||||||
|
} else {
|
||||||
// Folded/unordered FTS candidate: the
|
// Folded/unordered FTS candidate: the
|
||||||
// pattern never occurs — drop it.
|
// pattern never occurs — drop it.
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
}
|
}
|
||||||
let count_cs = pattern.count(text, false);
|
|
||||||
let (count, stage) = if count_cs > 0 {
|
|
||||||
(count_cs, 5)
|
|
||||||
} else {
|
|
||||||
(count_ci, 6)
|
|
||||||
};
|
|
||||||
(stage as f64 + count_frac(count), stage as u8, Some(snip))
|
|
||||||
}
|
|
||||||
// Wildcard: its snippet is cut around a single
|
|
||||||
// leftmost match, so the count is not a by-product of
|
|
||||||
// it and each step stays its own scan.
|
|
||||||
None => {
|
|
||||||
let count_cs = pattern.count(text, false);
|
|
||||||
let (count, stage) = if count_cs > 0 {
|
|
||||||
(count_cs, 5)
|
|
||||||
} else {
|
|
||||||
let count_ci = pattern.count_folded(&lower);
|
|
||||||
if count_ci == 0 {
|
|
||||||
return Ok(RowHit::Skip);
|
|
||||||
}
|
|
||||||
(count_ci, 6)
|
|
||||||
};
|
|
||||||
let snip = super::text_snippet(pattern, text, &lower);
|
|
||||||
(stage as f64 + count_frac(count), stage as u8, snip)
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
if !folded {
|
||||||
|
fold_into(&mut lower, text);
|
||||||
}
|
}
|
||||||
|
let snip = super::text_snippet(pattern, text, &lower);
|
||||||
|
(stage as f64 + count_frac(count), stage as u8, snip)
|
||||||
}
|
}
|
||||||
// No stored text: can't case-verify or count. On the
|
// No stored text: can't case-verify or count. On the
|
||||||
// FTS-narrowed path accept at the bottom of rank 6 as
|
// FTS-narrowed path accept at the bottom of rank 6 as
|
||||||
|
|
@ -471,7 +317,7 @@ impl<'a> Cx<'a> {
|
||||||
let (size, mtime) = size_and_mtime(row)?;
|
let (size, mtime) = size_and_mtime(row)?;
|
||||||
Ok(RowHit::Emit(SearchHit {
|
Ok(RowHit::Emit(SearchHit {
|
||||||
file_id,
|
file_id,
|
||||||
name: name.to_string(),
|
name: col(row, 1)?,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
@ -493,12 +339,11 @@ impl<'a> Cx<'a> {
|
||||||
if query.pattern.is_wildcard() {
|
if query.pattern.is_wildcard() {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
// The term goes in as typed: the matcher folds in its mask table, so
|
let folded_term = query.term.to_ascii_lowercase();
|
||||||
// neither side is lowercased here or per row.
|
let Some(k) = edit_budget(folded_term.len(), self.options.fuzzy_max_edits) else {
|
||||||
let Some(k) = edit_budget(query.term.len(), self.options.fuzzy_max_edits) else {
|
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
let Some(bitap) = Bitap::new(query.term.as_bytes(), k) else {
|
let Some(bitap) = Bitap::new(folded_term.as_bytes(), k) else {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
let with_paths = path_tiers_enabled(&query.pattern);
|
let with_paths = path_tiers_enabled(&query.pattern);
|
||||||
|
|
@ -513,16 +358,18 @@ impl<'a> Cx<'a> {
|
||||||
params,
|
params,
|
||||||
CANCEL_CHECK_ROWS,
|
CANCEL_CHECK_ROWS,
|
||||||
Some(DeferSlot::FuzzyPath),
|
Some(DeferSlot::FuzzyPath),
|
||||||
|cx, row, file_id, path, name| {
|
|cx, row, file_id, path| {
|
||||||
|
let name: String = col(row, 1)?;
|
||||||
// The name wins when both fire; only a name miss falls
|
// The name wins when both fire; only a name miss falls
|
||||||
// through to the path tier. Both fields are read as stored —
|
// through to the path tier.
|
||||||
// this is a whole-table scan, and the two `to_ascii_lowercase`
|
let folded_name = name.to_ascii_lowercase();
|
||||||
// calls that used to stand here were two allocations and two
|
let (rank, field, range) = match bitap
|
||||||
// copies for every row in the index, per keystroke.
|
.best_distance_and_first(folded_name.as_bytes())
|
||||||
let (rank, field, range) = match bitap.best_distance_and_first(name.as_bytes()) {
|
{
|
||||||
Some((distance, range)) => (7.0 + 0.1 * distance as f64, name, range),
|
Some((distance, range)) => (7.0 + 0.1 * distance as f64, name.as_str(), range),
|
||||||
None if with_paths => {
|
None if with_paths => {
|
||||||
match bitap.best_distance_and_first(path.as_bytes()) {
|
let folded_path = path.to_ascii_lowercase();
|
||||||
|
match bitap.best_distance_and_first(folded_path.as_bytes()) {
|
||||||
Some((distance, range)) => (11.0 + 0.1 * distance as f64, path, range),
|
Some((distance, range)) => (11.0 + 0.1 * distance as f64, path, range),
|
||||||
None => return Ok(RowHit::Skip),
|
None => return Ok(RowHit::Skip),
|
||||||
}
|
}
|
||||||
|
|
@ -544,7 +391,7 @@ impl<'a> Cx<'a> {
|
||||||
let (size, mtime) = size_and_mtime(row)?;
|
let (size, mtime) = size_and_mtime(row)?;
|
||||||
let hit = SearchHit {
|
let hit = SearchHit {
|
||||||
file_id,
|
file_id,
|
||||||
name: name.to_string(),
|
name,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
@ -577,77 +424,25 @@ impl<'a> Cx<'a> {
|
||||||
if self.query.pattern.is_wildcard() {
|
if self.query.pattern.is_wildcard() {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
// As typed; the matcher folds in its mask table.
|
let folded_term = self.query.term.to_ascii_lowercase();
|
||||||
let Some(k) = edit_budget(self.query.term.len(), self.options.fuzzy_max_edits) else {
|
let Some(k) = edit_budget(folded_term.len(), self.options.fuzzy_max_edits) else {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
let Some(bitap) = Bitap::new(self.query.term.as_bytes(), k) else {
|
let Some(bitap) = Bitap::new(folded_term.as_bytes(), k) else {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The candidate set. Without a prefilter this pass reads *every* stored
|
let sql = format!(
|
||||||
// document in the index on every keystroke — decompressing each one and
|
"SELECT {}, dt.text_zstd \
|
||||||
// running bitap over it — which is the most expensive thing the cascade
|
FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}",
|
||||||
// does by a wide margin.
|
HIT_COLUMNS, self.query.filter_sql
|
||||||
//
|
);
|
||||||
// The pigeonhole split gives a sound narrowing: at most `k` of the
|
let params = self.params_with_filters(Vec::new());
|
||||||
// `k + 1` chunks can be damaged, so at least one survives verbatim in
|
// One decoder and one fold buffer for the whole scan, reused per row.
|
||||||
// any document that matches, and asking the trigram index for "holds
|
|
||||||
// any one of these chunks" is a superset of what this pass can accept.
|
|
||||||
// See [`pigeonhole_chunks`] for the argument, and
|
|
||||||
// `tests/fuzzy_prefilter_fuzz.rs` for what defends it.
|
|
||||||
//
|
|
||||||
// Only a superset — the chunks are three characters and match plenty of
|
|
||||||
// documents that do not match the term at all. Every candidate is still
|
|
||||||
// decompressed and bitap-verified below; nothing about ranking or
|
|
||||||
// acceptance changes, only how many rows get that far.
|
|
||||||
//
|
|
||||||
// `None` means the term is too short to split (below `3 × (k + 1)`
|
|
||||||
// characters), and then there is no prefilter to be had and the pass
|
|
||||||
// scans as it always did.
|
|
||||||
let (sql, params) = match pigeonhole_chunks(&self.query.term, k) {
|
|
||||||
Some(chunks) => {
|
|
||||||
// Every chunk is quoted into inertness: a chunk is a slice of
|
|
||||||
// whatever the user typed, so it can contain `"`, `*`, `:`,
|
|
||||||
// `NEAR` and the rest of FTS5's syntax, and an unquoted one
|
|
||||||
// would be a syntax error instead of a search.
|
|
||||||
let expr = chunks
|
|
||||||
.iter()
|
|
||||||
.map(|c| format!("text: {}", quote_phrase(c)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" OR ");
|
|
||||||
(
|
|
||||||
format!(
|
|
||||||
"SELECT {}, dt.text_zstd \
|
|
||||||
FROM searchabletext \
|
|
||||||
JOIN files f ON f.id = searchabletext.rowid \
|
|
||||||
JOIN documents_text dt ON dt.file_id = f.id \
|
|
||||||
WHERE searchabletext MATCH ?{}",
|
|
||||||
HIT_COLUMNS, self.query.filter_sql
|
|
||||||
),
|
|
||||||
self.params_with_filters(vec![rusqlite::types::Value::Text(format!(
|
|
||||||
"({})",
|
|
||||||
expr
|
|
||||||
))]),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
None => (
|
|
||||||
format!(
|
|
||||||
"SELECT {}, dt.text_zstd \
|
|
||||||
FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}",
|
|
||||||
HIT_COLUMNS, self.query.filter_sql
|
|
||||||
),
|
|
||||||
self.params_with_filters(Vec::new()),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
// One decoder for the whole scan, reused per row. No fold buffer any
|
|
||||||
// more: this pass reads every stored document in the index on every
|
|
||||||
// keystroke, and it used to copy and lowercase each one — up to
|
|
||||||
// `maximum_text_size` per row — purely to give bitap a folded
|
|
||||||
// haystack. The matcher folds itself now.
|
|
||||||
let mut doc = crate::db::repo::DocDecoder::new()?;
|
let mut doc = crate::db::repo::DocDecoder::new()?;
|
||||||
|
let mut folded = String::new();
|
||||||
// Decompression dominates: check cancellation every row.
|
// Decompression dominates: check cancellation every row.
|
||||||
self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path, name| {
|
self.scan_pass(&sql, params, 1, None, |cx, row, file_id, path| {
|
||||||
let blob: Option<&[u8]> = row
|
let blob: Option<&[u8]> = row
|
||||||
.get_ref(5)
|
.get_ref(5)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
|
|
@ -656,7 +451,10 @@ impl<'a> Cx<'a> {
|
||||||
let Some(text) = blob.and_then(|b| doc.decode(b)) else {
|
let Some(text) = blob.and_then(|b| doc.decode(b)) else {
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
};
|
};
|
||||||
let Some((count, snip)) = super::fuzzy_snippet(&bitap, text) else {
|
// ASCII folding is byte-length preserving, so ranges found in
|
||||||
|
// the folded buffer are valid in the original.
|
||||||
|
fold_into(&mut folded, text);
|
||||||
|
let Some((count, snip)) = super::fuzzy_snippet(&bitap, text, &folded) else {
|
||||||
return Ok(RowHit::Skip);
|
return Ok(RowHit::Skip);
|
||||||
};
|
};
|
||||||
if !cx.regex_accepts(file_id, path, Some(text))? {
|
if !cx.regex_accepts(file_id, path, Some(text))? {
|
||||||
|
|
@ -665,7 +463,7 @@ impl<'a> Cx<'a> {
|
||||||
let (size, mtime) = size_and_mtime(row)?;
|
let (size, mtime) = size_and_mtime(row)?;
|
||||||
Ok(RowHit::Emit(SearchHit {
|
Ok(RowHit::Emit(SearchHit {
|
||||||
file_id,
|
file_id,
|
||||||
name: name.to_string(),
|
name: col(row, 1)?,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
@ -680,46 +478,38 @@ impl<'a> Cx<'a> {
|
||||||
/// entirely and runs on every name, falling back to the full path.
|
/// entirely and runs on every name, falling back to the full path.
|
||||||
/// Name hits reuse rank 4, path hits defer to rank 10, so the GUI's
|
/// Name hits reuse rank 4, path hits defer to rank 10, so the GUI's
|
||||||
/// stage-based rendering needs no new cases.
|
/// stage-based rendering needs no new cases.
|
||||||
///
|
|
||||||
/// Narrowed by the pattern's required literals where it has any — the same
|
|
||||||
/// two-column `LIKE` the wildcard filename pass uses, for the same reason
|
|
||||||
/// (see [`crate::search::prefilter::Required::like_predicate`]). Without one
|
|
||||||
/// this evaluates the user's regex against every name *and* every path in
|
|
||||||
/// the index, per keystroke; a pattern like `\d+` still does, because there
|
|
||||||
/// is no literal in it to filter on.
|
|
||||||
pub(super) fn pass_regex_name(&mut self) -> Result<bool, String> {
|
pub(super) fn pass_regex_name(&mut self) -> Result<bool, String> {
|
||||||
let query = self.query;
|
let query = self.query;
|
||||||
let re = query.regex.as_ref().expect("regex-only pass list");
|
let re = query.regex.as_ref().expect("regex-only pass list");
|
||||||
let (predicate, terms) = match re.required().and_then(|r| r.like_predicate()) {
|
|
||||||
Some((sql, params)) => (sql, params),
|
|
||||||
None => ("1=1".to_string(), Vec::new()),
|
|
||||||
};
|
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT {} FROM files f WHERE {}{}",
|
"SELECT {} FROM files f WHERE 1=1{}",
|
||||||
HIT_COLUMNS, predicate, query.filter_sql
|
HIT_COLUMNS, query.filter_sql
|
||||||
);
|
);
|
||||||
let params = self.params_with_filters(terms);
|
let params = self.params_with_filters(Vec::new());
|
||||||
self.scan_pass(
|
self.scan_pass(
|
||||||
&sql,
|
&sql,
|
||||||
params,
|
params,
|
||||||
CANCEL_CHECK_ROWS,
|
CANCEL_CHECK_ROWS,
|
||||||
Some(DeferSlot::Path),
|
Some(DeferSlot::Path),
|
||||||
|_cx, row, file_id, path, name| {
|
|_cx, row, file_id, path| {
|
||||||
|
let name: String = col(row, 1)?;
|
||||||
// The name is the better hit; only a name miss falls through
|
// The name is the better hit; only a name miss falls through
|
||||||
// to the path tier — mirroring pass A.
|
// to the path tier — mirroring pass A.
|
||||||
let (rank, match_range, is_path_tier) = match re.find_first(name) {
|
let (rank, match_range, is_path_tier) = match re.find_first(&name) {
|
||||||
Some(r) => (4.0, (r.start, r.end), false),
|
Some(r) => (4.0, (r.start, r.end), false),
|
||||||
None => match re.find_first(path) {
|
None => match re.find_first(path) {
|
||||||
Some(r) => (10.0, (r.start, r.end), true),
|
Some(r) => (10.0, (r.start, r.end), true),
|
||||||
None => return Ok(RowHit::Skip),
|
None => return Ok(RowHit::Skip),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let snip =
|
let snip = snippet::whole_field(
|
||||||
snippet::whole_field(if is_path_tier { path } else { name }, match_range);
|
if is_path_tier { path } else { name.as_str() },
|
||||||
|
match_range,
|
||||||
|
);
|
||||||
let (size, mtime) = size_and_mtime(row)?;
|
let (size, mtime) = size_and_mtime(row)?;
|
||||||
let hit = SearchHit {
|
let hit = SearchHit {
|
||||||
file_id,
|
file_id,
|
||||||
name: name.to_string(),
|
name,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
@ -737,44 +527,22 @@ impl<'a> Cx<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regex-only pass over every stored document text, reusing rank 6.
|
/// Regex-only pass over every stored document text, reusing rank 6.
|
||||||
///
|
|
||||||
/// Narrowed by the pattern's required literals through the trigram index,
|
|
||||||
/// exactly as the fuzzy full-text pass narrows by its pigeonhole chunks —
|
|
||||||
/// both are "at least one of these strings is present", and both go through
|
|
||||||
/// [`crate::search::prefilter::Required::fts_expr`]. Without a usable set
|
|
||||||
/// this decompresses and regex-scans every stored document in the index on
|
|
||||||
/// every keystroke.
|
|
||||||
pub(super) fn pass_regex_content(&mut self) -> Result<bool, String> {
|
pub(super) fn pass_regex_content(&mut self) -> Result<bool, String> {
|
||||||
let query = self.query;
|
let query = self.query;
|
||||||
let re = query.regex.as_ref().expect("regex-only pass list");
|
let re = query.regex.as_ref().expect("regex-only pass list");
|
||||||
let (sql, params) = match re.required().and_then(|r| r.fts_expr()) {
|
let sql = format!(
|
||||||
Some(expr) => (
|
"SELECT {}, dt.text_zstd \
|
||||||
format!(
|
FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}",
|
||||||
"SELECT {}, dt.text_zstd \
|
HIT_COLUMNS, query.filter_sql
|
||||||
FROM searchabletext \
|
);
|
||||||
JOIN files f ON f.id = searchabletext.rowid \
|
let params = self.params_with_filters(Vec::new());
|
||||||
JOIN documents_text dt ON dt.file_id = f.id \
|
|
||||||
WHERE searchabletext MATCH ?{}",
|
|
||||||
HIT_COLUMNS, query.filter_sql
|
|
||||||
),
|
|
||||||
self.params_with_filters(vec![rusqlite::types::Value::Text(expr)]),
|
|
||||||
),
|
|
||||||
None => (
|
|
||||||
format!(
|
|
||||||
"SELECT {}, dt.text_zstd \
|
|
||||||
FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}",
|
|
||||||
HIT_COLUMNS, query.filter_sql
|
|
||||||
),
|
|
||||||
self.params_with_filters(Vec::new()),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
let snippet_opts = snippet::Options {
|
let snippet_opts = snippet::Options {
|
||||||
approx_chars: SNIPPET_WINDOW_CHARS,
|
approx_chars: SNIPPET_WINDOW_CHARS,
|
||||||
};
|
};
|
||||||
// One decoder for the whole scan, reused per row.
|
// One decoder for the whole scan, reused per row.
|
||||||
let mut doc = crate::db::repo::DocDecoder::new()?;
|
let mut doc = crate::db::repo::DocDecoder::new()?;
|
||||||
// Decompression dominates: check cancellation every row.
|
// Decompression dominates: check cancellation every row.
|
||||||
self.scan_pass(&sql, params, 1, None, |_cx, row, file_id, path, name| {
|
self.scan_pass(&sql, params, 1, None, |_cx, row, file_id, path| {
|
||||||
let blob: Option<&[u8]> = row
|
let blob: Option<&[u8]> = row
|
||||||
.get_ref(5)
|
.get_ref(5)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
|
|
@ -796,7 +564,7 @@ impl<'a> Cx<'a> {
|
||||||
let (size, mtime) = size_and_mtime(row)?;
|
let (size, mtime) = size_and_mtime(row)?;
|
||||||
Ok(RowHit::Emit(SearchHit {
|
Ok(RowHit::Emit(SearchHit {
|
||||||
file_id,
|
file_id,
|
||||||
name: name.to_string(),
|
name: col(row, 1)?,
|
||||||
path: path.to_string(),
|
path: path.to_string(),
|
||||||
size,
|
size,
|
||||||
mtime,
|
mtime,
|
||||||
|
|
|
||||||
|
|
@ -57,25 +57,16 @@ pub fn find_duplicate_groups(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `ORDER BY parent, name` rather than by path: it is the stored key, so
|
|
||||||
// the sort is free, and it groups a directory's copies together — which is
|
|
||||||
// what the tab shows anyway.
|
|
||||||
let mut member_stmt = conn
|
let mut member_stmt = conn
|
||||||
.prepare(
|
.prepare("SELECT id, name, path, size, mtime FROM files WHERE hash = ?1 ORDER BY path")
|
||||||
"SELECT id, name, parent, size, mtime FROM files \
|
|
||||||
WHERE hash = ?1 ORDER BY parent, name",
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
for group in &mut groups {
|
for group in &mut groups {
|
||||||
let rows = member_stmt
|
let rows = member_stmt
|
||||||
.query_map(params![group.hash], |r| {
|
.query_map(params![group.hash], |r| {
|
||||||
let name: String = r.get(1)?;
|
|
||||||
let parent: String = r.get(2)?;
|
|
||||||
let path = format!("{}{}", parent, name);
|
|
||||||
Ok((
|
Ok((
|
||||||
r.get::<_, i64>(0)?,
|
r.get::<_, i64>(0)?,
|
||||||
name,
|
r.get::<_, String>(1)?,
|
||||||
path,
|
r.get::<_, String>(2)?,
|
||||||
r.get::<_, i64>(3)?.max(0) as u64,
|
r.get::<_, i64>(3)?.max(0) as u64,
|
||||||
r.get::<_, i64>(4)?,
|
r.get::<_, i64>(4)?,
|
||||||
))
|
))
|
||||||
|
|
@ -100,12 +91,13 @@ mod tests {
|
||||||
let p = crate::testutil::scratch_dir("dups").join("index.sqlite");
|
let p = crate::testutil::scratch_dir("dups").join("index.sqlite");
|
||||||
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
let tx = conn.transaction().unwrap();
|
let tx = conn.transaction().unwrap();
|
||||||
let add = |name: &str, size: u64, hash: Option<&[u8]>| {
|
let add = |name: &str, path: &str, size: u64, hash: Option<&[u8]>| {
|
||||||
insert_file(
|
insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name,
|
name,
|
||||||
parent: "/d/",
|
path,
|
||||||
|
parent: "/d",
|
||||||
size,
|
size,
|
||||||
mtime: 1_700_000_000,
|
mtime: 1_700_000_000,
|
||||||
mime: None,
|
mime: None,
|
||||||
|
|
@ -118,20 +110,20 @@ mod tests {
|
||||||
.expect("unique path");
|
.expect("unique path");
|
||||||
};
|
};
|
||||||
// Triple group of small files: redundant = 10 × 2 = 20.
|
// Triple group of small files: redundant = 10 × 2 = 20.
|
||||||
add("a1.txt", 10, Some(b"AAA"));
|
add("a1.txt", "/d/a1.txt", 10, Some(b"AAA"));
|
||||||
add("a2.txt", 10, Some(b"AAA"));
|
add("a2.txt", "/d/a2.txt", 10, Some(b"AAA"));
|
||||||
add("a3.txt", 10, Some(b"AAA"));
|
add("a3.txt", "/d/a3.txt", 10, Some(b"AAA"));
|
||||||
// Pair of large files: redundant = 100 × 1 = 100 — sorts first
|
// Pair of large files: redundant = 100 × 1 = 100 — sorts first
|
||||||
// despite the smaller member count.
|
// despite the smaller member count.
|
||||||
add("b1.txt", 100, Some(b"BBB"));
|
add("b1.txt", "/d/b1.txt", 100, Some(b"BBB"));
|
||||||
add("b2.txt", 100, Some(b"BBB"));
|
add("b2.txt", "/d/b2.txt", 100, Some(b"BBB"));
|
||||||
// Singletons and NULL hashes never appear.
|
// Singletons and NULL hashes never appear.
|
||||||
add("c.txt", 30, Some(b"CCC"));
|
add("c.txt", "/d/c.txt", 30, Some(b"CCC"));
|
||||||
add("n1.txt", 40, None);
|
add("n1.txt", "/d/n1.txt", 40, None);
|
||||||
add("n2.txt", 40, None);
|
add("n2.txt", "/d/n2.txt", 40, None);
|
||||||
// Zero-size files are trivially identical — excluded outright.
|
// Zero-size files are trivially identical — excluded outright.
|
||||||
add("z1.txt", 0, Some(b"ZZZ"));
|
add("z1.txt", "/d/z1.txt", 0, Some(b"ZZZ"));
|
||||||
add("z2.txt", 0, Some(b"ZZZ"));
|
add("z2.txt", "/d/z2.txt", 0, Some(b"ZZZ"));
|
||||||
tx.commit().unwrap();
|
tx.commit().unwrap();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
p
|
p
|
||||||
|
|
|
||||||
|
|
@ -5,17 +5,9 @@
|
||||||
//! (insertion / deletion / substitution); the u64 bit-parallel update costs
|
//! (insertion / deletion / substitution); the u64 bit-parallel update costs
|
||||||
//! O(k) word ops per haystack byte with zero allocations.
|
//! O(k) word ops per haystack byte with zero allocations.
|
||||||
//!
|
//!
|
||||||
//! Matching is **ASCII-case-insensitive, in the automaton itself**: the mask
|
//! Callers fold both sides to ASCII lowercase first (the pipeline-wide
|
||||||
//! table sets a bit for both cases of every pattern byte, so neither side needs
|
//! convention). Patterns are limited to 64 bytes by the machine word; the
|
||||||
//! folding first. That is not a convenience — it is what lets the whole-table
|
//! cascade skips fuzzy stages for longer terms.
|
||||||
//! fuzzy passes run. Folding the haystack meant a `to_ascii_lowercase()` per
|
|
||||||
//! scanned filename *and* per scanned path, and, in the full-text pass, a
|
|
||||||
//! complete copy-and-fold of every stored document on every keystroke. Bytes
|
|
||||||
//! above 0x7F are untouched by ASCII folding, so setting both cases in the mask
|
|
||||||
//! accepts exactly what folding both sides accepted.
|
|
||||||
//!
|
|
||||||
//! Patterns are limited to 64 bytes by the machine word; the cascade skips
|
|
||||||
//! fuzzy stages for longer terms.
|
|
||||||
|
|
||||||
/// Registers the automaton needs: one per error count `0..=k`.
|
/// Registers the automaton needs: one per error count `0..=k`.
|
||||||
///
|
///
|
||||||
|
|
@ -27,7 +19,7 @@
|
||||||
const MAX_REGISTERS: usize = 22;
|
const MAX_REGISTERS: usize = 22;
|
||||||
|
|
||||||
pub struct Bitap {
|
pub struct Bitap {
|
||||||
/// `masks[c]` has bit `i` set iff `pattern[i] == c` ignoring ASCII case.
|
/// `masks[c]` has bit `i` set iff `pattern[i] == c`.
|
||||||
masks: [u64; 256],
|
masks: [u64; 256],
|
||||||
/// The same table for the *reversed* pattern, which is what lets
|
/// The same table for the *reversed* pattern, which is what lets
|
||||||
/// [`Bitap::match_start`] find where a match began by scanning backwards
|
/// [`Bitap::match_start`] find where a match began by scanning backwards
|
||||||
|
|
@ -43,11 +35,6 @@ impl Bitap {
|
||||||
/// `None` when the pattern is empty, longer than 64 bytes, or the edit
|
/// `None` when the pattern is empty, longer than 64 bytes, or the edit
|
||||||
/// budget does not fit the registers (`reset` shifts by `k`, and the
|
/// budget does not fit the registers (`reset` shifts by `k`, and the
|
||||||
/// register array holds [`MAX_REGISTERS`]).
|
/// register array holds [`MAX_REGISTERS`]).
|
||||||
///
|
|
||||||
/// The pattern need not be folded: each byte's bit is set under **both**
|
|
||||||
/// ASCII cases, which is what makes the automaton case-insensitive and the
|
|
||||||
/// haystack fold unnecessary. For a byte that is not an ASCII letter the
|
|
||||||
/// two cases are the same byte and the second write is a no-op.
|
|
||||||
pub fn new(pattern: &[u8], k: usize) -> Option<Bitap> {
|
pub fn new(pattern: &[u8], k: usize) -> Option<Bitap> {
|
||||||
if pattern.is_empty() || pattern.len() > 64 || k >= MAX_REGISTERS {
|
if pattern.is_empty() || pattern.len() > 64 || k >= MAX_REGISTERS {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -55,12 +42,8 @@ impl Bitap {
|
||||||
let mut masks = [0u64; 256];
|
let mut masks = [0u64; 256];
|
||||||
let mut rev_masks = [0u64; 256];
|
let mut rev_masks = [0u64; 256];
|
||||||
for (i, &b) in pattern.iter().enumerate() {
|
for (i, &b) in pattern.iter().enumerate() {
|
||||||
let (lower, upper) = (b.to_ascii_lowercase(), b.to_ascii_uppercase());
|
masks[b as usize] |= 1u64 << i;
|
||||||
let (fwd, rev) = (1u64 << i, 1u64 << (pattern.len() - 1 - i));
|
rev_masks[b as usize] |= 1u64 << (pattern.len() - 1 - i);
|
||||||
masks[lower as usize] |= fwd;
|
|
||||||
masks[upper as usize] |= fwd;
|
|
||||||
rev_masks[lower as usize] |= rev;
|
|
||||||
rev_masks[upper as usize] |= rev;
|
|
||||||
}
|
}
|
||||||
Some(Bitap {
|
Some(Bitap {
|
||||||
masks,
|
masks,
|
||||||
|
|
@ -241,70 +224,6 @@ impl Bitap {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-exported: the trigram floor is a property of the index, not of fuzzy
|
|
||||||
/// matching, and the regex prefilter applies the same one. See
|
|
||||||
/// [`crate::search::prefilter`].
|
|
||||||
pub use super::prefilter::TRIGRAM_FLOOR;
|
|
||||||
|
|
||||||
/// Split `term` into `k + 1` consecutive chunks of at least [`TRIGRAM_FLOOR`]
|
|
||||||
/// characters each, for the fuzzy full-text pass's candidate prefilter.
|
|
||||||
/// `None` when the term is too short to divide that way.
|
|
||||||
///
|
|
||||||
/// # Why this is a sound prefilter
|
|
||||||
///
|
|
||||||
/// The chunks **partition** the term: consecutive, disjoint, and together the
|
|
||||||
/// whole of it. Suppose the term occurs somewhere in a document within `k`
|
|
||||||
/// edits. Each edit falls inside at most one chunk, so at most `k` of the
|
|
||||||
/// `k + 1` chunks are touched — and therefore at least one chunk survives
|
|
||||||
/// *verbatim* in the document. Asking the index for "any document containing
|
|
||||||
/// chunk 1, or chunk 2, … " is consequently a **superset** of the documents the
|
|
||||||
/// pass can accept, and narrowing to it cannot lose a hit. It can admit
|
|
||||||
/// documents that do not match at all, which is harmless: every candidate is
|
|
||||||
/// still verified by the bitap scan that follows.
|
|
||||||
///
|
|
||||||
/// The partition is what makes it work, so it must stay one. Chunks that
|
|
||||||
/// overlapped, or that skipped part of the term, would let `k` edits damage
|
|
||||||
/// every chunk and the argument would collapse silently — as a search that
|
|
||||||
/// quietly stops finding things.
|
|
||||||
///
|
|
||||||
/// # Why the floor, and why chars rather than bytes
|
|
||||||
///
|
|
||||||
/// Below `3 * (k + 1)` characters some chunk would be shorter than a trigram
|
|
||||||
/// and match no token at all, turning the "superset" into an empty set. There
|
|
||||||
/// is no prefilter for such a term and the caller must scan; that is what
|
|
||||||
/// `None` says. Note this is reachable only when `fuzzy_max_edits` *binds* —
|
|
||||||
/// [`edit_budget`]'s own ladder gives one edit per three characters, so a term
|
|
||||||
/// long enough for `k` edits by length alone is never long enough to split.
|
|
||||||
///
|
|
||||||
/// Splitting on characters, not bytes: a byte split can land inside a UTF-8
|
|
||||||
/// sequence, and the halves would be handed to FTS5 as phrases that no longer
|
|
||||||
/// spell anything the tokenizer indexed.
|
|
||||||
pub fn pigeonhole_chunks(term: &str, k: usize) -> Option<Vec<&str>> {
|
|
||||||
let chunks = k + 1;
|
|
||||||
let total = term.chars().count();
|
|
||||||
if total < TRIGRAM_FLOOR * chunks {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// Char-boundary offsets, plus the end, so a chunk can be sliced by
|
|
||||||
// character index without re-walking the string per chunk.
|
|
||||||
let mut bounds: Vec<usize> = term.char_indices().map(|(at, _)| at).collect();
|
|
||||||
bounds.push(term.len());
|
|
||||||
|
|
||||||
// The remainder is spread one character at a time over the leading chunks
|
|
||||||
// rather than dumped on the last, so no chunk is left near the floor while
|
|
||||||
// another is long. Every chunk is a candidate; the shortest is the weakest
|
|
||||||
// filter, so the useful thing is to keep the shortest as long as possible.
|
|
||||||
let (base, extra) = (total / chunks, total % chunks);
|
|
||||||
let mut out = Vec::with_capacity(chunks);
|
|
||||||
let mut start = 0usize;
|
|
||||||
for i in 0..chunks {
|
|
||||||
let end = start + base + usize::from(i < extra);
|
|
||||||
out.push(&term[bounds[start]..bounds[end]]);
|
|
||||||
start = end;
|
|
||||||
}
|
|
||||||
Some(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The cascade's edit-distance budget for a folded term: one edit per
|
/// The cascade's edit-distance budget for a folded term: one edit per
|
||||||
/// three characters, capped by `[search].fuzzy_max_edits`. Terms outside
|
/// three characters, capped by `[search].fuzzy_max_edits`. Terms outside
|
||||||
/// 3..=64 bytes skip the fuzzy stages entirely (< 3 is noise, > 64 exceeds
|
/// 3..=64 bytes skip the fuzzy stages entirely (< 3 is noise, > 64 exceeds
|
||||||
|
|
@ -350,35 +269,6 @@ mod tests {
|
||||||
&hay[s..e]
|
&hay[s..e]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The property that removes the fold from every caller: a pattern and a
|
|
||||||
/// haystack that differ only in case match at distance zero, with neither
|
|
||||||
/// side lowercased first.
|
|
||||||
#[test]
|
|
||||||
fn case_is_free_without_folding_either_side() {
|
|
||||||
assert_eq!(best("HELLO", "say hello world", 2), Some(0));
|
|
||||||
assert_eq!(best("hello", "say HELLO world", 2), Some(0));
|
|
||||||
assert_eq!(best("HeLLo", "say hEllO world", 0), Some(0));
|
|
||||||
// A case difference is not an edit, so the budget stays available for
|
|
||||||
// real ones: one substitution on top of four case flips still fits k=1.
|
|
||||||
assert_eq!(best("hello", "say HeXLO world", 1), Some(1));
|
|
||||||
// And the marked span is the occurrence as it appears in the haystack.
|
|
||||||
assert_eq!(marked("QUARTZITE", "the quartzite slab", 2), "quartzite");
|
|
||||||
assert_eq!(marked_best("quartzite", "the QUARTZITE slab", 2), "QUARTZITE");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-ASCII bytes are untouched by ASCII case folding, so setting both
|
|
||||||
/// cases in the mask cannot make two different multi-byte characters
|
|
||||||
/// collide.
|
|
||||||
#[test]
|
|
||||||
fn non_ascii_bytes_are_not_folded_together() {
|
|
||||||
// 'é' is 0xC3 0xA9 and 'É' is 0xC3 0x89 — distinct byte sequences that
|
|
||||||
// ASCII folding leaves distinct. The second byte differs, so this is
|
|
||||||
// one substitution, not a free case flip.
|
|
||||||
assert_eq!(best("café", "le café", 0), Some(0));
|
|
||||||
assert_eq!(best("café", "le CAFÉ", 0), None, "É is not a fold of é");
|
|
||||||
assert_eq!(best("café", "le CAFÉ", 1), Some(1), "and costs one edit");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn exact_substring_is_distance_zero() {
|
fn exact_substring_is_distance_zero() {
|
||||||
assert_eq!(best("hello", "say hello world", 2), Some(0));
|
assert_eq!(best("hello", "say hello world", 2), Some(0));
|
||||||
|
|
@ -621,11 +511,6 @@ mod tests {
|
||||||
|
|
||||||
/// Brute-force oracle: minimum Levenshtein distance between `pattern`
|
/// Brute-force oracle: minimum Levenshtein distance between `pattern`
|
||||||
/// and any substring of `hay`, capped at k.
|
/// and any substring of `hay`, capped at k.
|
||||||
///
|
|
||||||
/// Compares **ignoring ASCII case**, matching the automaton: the mask table
|
|
||||||
/// sets both cases of every pattern byte, so a case difference is not an
|
|
||||||
/// edit. On an all-lowercase alphabet this is identical to an exact
|
|
||||||
/// comparison, so the older cases below are unaffected by the fold.
|
|
||||||
fn oracle(pattern: &[u8], hay: &[u8], k: usize) -> Option<usize> {
|
fn oracle(pattern: &[u8], hay: &[u8], k: usize) -> Option<usize> {
|
||||||
// An occurrence must end at some text position; empty text has none.
|
// An occurrence must end at some text position; empty text has none.
|
||||||
// Without this, k >= pattern-length "matches" empty text by deleting
|
// Without this, k >= pattern-length "matches" empty text by deleting
|
||||||
|
|
@ -643,11 +528,7 @@ mod tests {
|
||||||
for i in 1..=m {
|
for i in 1..=m {
|
||||||
cur[0] = i;
|
cur[0] = i;
|
||||||
for j in 1..=hay.len() {
|
for j in 1..=hay.len() {
|
||||||
let cost = if pattern[i - 1].eq_ignore_ascii_case(&hay[j - 1]) {
|
let cost = if pattern[i - 1] == hay[j - 1] { 0 } else { 1 };
|
||||||
0
|
|
||||||
} else {
|
|
||||||
1
|
|
||||||
};
|
|
||||||
cur[j] = (prev[j - 1] + cost).min(prev[j] + 1).min(cur[j - 1] + 1);
|
cur[j] = (prev[j - 1] + cost).min(prev[j] + 1).min(cur[j - 1] + 1);
|
||||||
}
|
}
|
||||||
std::mem::swap(&mut prev, &mut cur);
|
std::mem::swap(&mut prev, &mut cur);
|
||||||
|
|
@ -672,10 +553,7 @@ mod tests {
|
||||||
.wrapping_add(1442695040888963407);
|
.wrapping_add(1442695040888963407);
|
||||||
(seed >> 33) as usize
|
(seed >> 33) as usize
|
||||||
};
|
};
|
||||||
// Mixed case on both sides, which is the whole point: the matcher folds
|
let alphabet = b"abcx";
|
||||||
// in its mask table rather than having its haystack folded for it, and
|
|
||||||
// an alphabet of one case could never tell the two apart.
|
|
||||||
let alphabet = b"abcxABCX";
|
|
||||||
for _ in 0..500 {
|
for _ in 0..500 {
|
||||||
let plen = 3 + rng() % 6;
|
let plen = 3 + rng() % 6;
|
||||||
let hlen = rng() % 20;
|
let hlen = rng() % 20;
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@
|
||||||
pub mod cascade;
|
pub mod cascade;
|
||||||
pub mod duplicates;
|
pub mod duplicates;
|
||||||
pub mod fuzzy;
|
pub mod fuzzy;
|
||||||
pub mod prefilter;
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
@ -188,34 +187,11 @@ struct SearchRequest {
|
||||||
/// its statement, tagged with the generation that owns it.
|
/// its statement, tagged with the generation that owns it.
|
||||||
type InFlight = Arc<Mutex<Option<(u64, rusqlite::InterruptHandle)>>>;
|
type InFlight = Arc<Mutex<Option<(u64, rusqlite::InterruptHandle)>>>;
|
||||||
|
|
||||||
/// One-shot channel the worker answers a release request on.
|
|
||||||
type ReleaseAck = Arc<Mutex<Option<mpsc::Sender<()>>>>;
|
|
||||||
|
|
||||||
/// How long [`SearchService::release_connection`] waits for the worker to
|
|
||||||
/// answer. Long enough for it to finish a statement and drop the handle,
|
|
||||||
/// short enough that a wedged worker cannot hold up a rebuild.
|
|
||||||
const RELEASE_WAIT: Duration = Duration::from_secs(2);
|
|
||||||
|
|
||||||
/// What the worker thread accepts. A search is the overwhelming majority;
|
|
||||||
/// the release exists because dropping the held connection is something only
|
|
||||||
/// the worker can do, and it is parked in a 30-second `recv_timeout` where a
|
|
||||||
/// flag would not reach it.
|
|
||||||
enum WorkerMsg {
|
|
||||||
Search(SearchRequest),
|
|
||||||
/// Drop the held connection now, so whoever asked can delete or replace
|
|
||||||
/// the index file. On Windows an open handle makes that fail outright;
|
|
||||||
/// everywhere else it keeps the deleted inode's blocks pinned.
|
|
||||||
ReleaseConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SearchService {
|
pub struct SearchService {
|
||||||
req_tx: mpsc::Sender<WorkerMsg>,
|
req_tx: mpsc::Sender<SearchRequest>,
|
||||||
latest_gen: Arc<AtomicU64>,
|
latest_gen: Arc<AtomicU64>,
|
||||||
in_flight: InFlight,
|
in_flight: InFlight,
|
||||||
db_path: Arc<Mutex<PathBuf>>,
|
db_path: Arc<Mutex<PathBuf>>,
|
||||||
/// Where the worker reports that it has let the connection go; see
|
|
||||||
/// [`SearchService::release_connection`].
|
|
||||||
release_ack: ReleaseAck,
|
|
||||||
handle: Option<JoinHandle<()>>,
|
handle: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -238,12 +214,11 @@ impl SearchService {
|
||||||
notify: Arc<dyn Fn() + Send + Sync>,
|
notify: Arc<dyn Fn() + Send + Sync>,
|
||||||
idle_release: Duration,
|
idle_release: Duration,
|
||||||
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
|
) -> (SearchService, mpsc::Receiver<SearchUpdate>) {
|
||||||
let (req_tx, req_rx) = mpsc::channel::<WorkerMsg>();
|
let (req_tx, req_rx) = mpsc::channel::<SearchRequest>();
|
||||||
let (update_tx, update_rx) = mpsc::channel::<SearchUpdate>();
|
let (update_tx, update_rx) = mpsc::channel::<SearchUpdate>();
|
||||||
let latest_gen = Arc::new(AtomicU64::new(0));
|
let latest_gen = Arc::new(AtomicU64::new(0));
|
||||||
let in_flight: InFlight = Arc::new(Mutex::new(None));
|
let in_flight: InFlight = Arc::new(Mutex::new(None));
|
||||||
let db_path = Arc::new(Mutex::new(db_path));
|
let db_path = Arc::new(Mutex::new(db_path));
|
||||||
let release_ack: ReleaseAck = Arc::new(Mutex::new(None));
|
|
||||||
|
|
||||||
let worker = Worker {
|
let worker = Worker {
|
||||||
req_rx,
|
req_rx,
|
||||||
|
|
@ -252,7 +227,6 @@ impl SearchService {
|
||||||
latest_gen: latest_gen.clone(),
|
latest_gen: latest_gen.clone(),
|
||||||
in_flight: in_flight.clone(),
|
in_flight: in_flight.clone(),
|
||||||
db_path: db_path.clone(),
|
db_path: db_path.clone(),
|
||||||
release_ack: release_ack.clone(),
|
|
||||||
open: None,
|
open: None,
|
||||||
idle_release,
|
idle_release,
|
||||||
};
|
};
|
||||||
|
|
@ -267,7 +241,6 @@ impl SearchService {
|
||||||
latest_gen,
|
latest_gen,
|
||||||
in_flight,
|
in_flight,
|
||||||
db_path,
|
db_path,
|
||||||
release_ack,
|
|
||||||
handle: Some(handle),
|
handle: Some(handle),
|
||||||
},
|
},
|
||||||
update_rx,
|
update_rx,
|
||||||
|
|
@ -281,42 +254,14 @@ impl SearchService {
|
||||||
// Interrupt before enqueueing: an idle worker can dequeue the new
|
// Interrupt before enqueueing: an idle worker can dequeue the new
|
||||||
// request and be mid-statement within microseconds.
|
// request and be mid-statement within microseconds.
|
||||||
self.interrupt_stale();
|
self.interrupt_stale();
|
||||||
let _ = self.req_tx.send(WorkerMsg::Search(SearchRequest {
|
let _ = self.req_tx.send(SearchRequest {
|
||||||
generation,
|
generation,
|
||||||
input: input.to_string(),
|
input: input.to_string(),
|
||||||
options,
|
options,
|
||||||
}));
|
});
|
||||||
generation
|
generation
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the connection the worker is holding, and wait briefly for it.
|
|
||||||
///
|
|
||||||
/// The worker keeps its connection for [`IDLE_RELEASE`] after the last
|
|
||||||
/// query so a typing session runs against a warm page cache. That is the
|
|
||||||
/// right default and the wrong thing to be holding when the index file is
|
|
||||||
/// about to be deleted: on Windows the delete fails outright, and a
|
|
||||||
/// "Rebuild index" that silently did not rebuild is worse than a slow one.
|
|
||||||
///
|
|
||||||
/// Cancelled first, and not merely as a courtesy. The worker coalesces its
|
|
||||||
/// queue, so a search enqueued just before this — a keystroke, then a
|
|
||||||
/// click on *Rebuild index* — is still current when the release is found,
|
|
||||||
/// and the worker would acknowledge the release and then **reopen** the
|
|
||||||
/// connection to serve it. Bumping the generation makes that request stale,
|
|
||||||
/// so it is dropped at the generation check instead. Results against a file
|
|
||||||
/// about to be deleted are worth nothing anyway.
|
|
||||||
///
|
|
||||||
/// Best-effort past that — it returns once the worker acknowledges or the
|
|
||||||
/// wait elapses, and the caller proceeds either way.
|
|
||||||
pub fn release_connection(&self) {
|
|
||||||
self.cancel();
|
|
||||||
let (ack_tx, ack_rx) = mpsc::channel();
|
|
||||||
*crate::lock_ok(&self.release_ack) = Some(ack_tx);
|
|
||||||
if self.req_tx.send(WorkerMsg::ReleaseConnection).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _ = ack_rx.recv_timeout(RELEASE_WAIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cancel without starting anything new.
|
/// Cancel without starting anything new.
|
||||||
pub fn cancel(&self) {
|
pub fn cancel(&self) {
|
||||||
self.latest_gen.fetch_add(1, Ordering::SeqCst);
|
self.latest_gen.fetch_add(1, Ordering::SeqCst);
|
||||||
|
|
@ -375,13 +320,12 @@ pub fn classify_sql_err(error_msg: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Worker {
|
struct Worker {
|
||||||
req_rx: mpsc::Receiver<WorkerMsg>,
|
req_rx: mpsc::Receiver<SearchRequest>,
|
||||||
update_tx: mpsc::Sender<SearchUpdate>,
|
update_tx: mpsc::Sender<SearchUpdate>,
|
||||||
notify: Arc<dyn Fn() + Send + Sync>,
|
notify: Arc<dyn Fn() + Send + Sync>,
|
||||||
latest_gen: Arc<AtomicU64>,
|
latest_gen: Arc<AtomicU64>,
|
||||||
in_flight: InFlight,
|
in_flight: InFlight,
|
||||||
db_path: Arc<Mutex<PathBuf>>,
|
db_path: Arc<Mutex<PathBuf>>,
|
||||||
release_ack: ReleaseAck,
|
|
||||||
/// The connection, and the index generation and path it was opened
|
/// The connection, and the index generation and path it was opened
|
||||||
/// against. See [`Worker::take_connection`].
|
/// against. See [`Worker::take_connection`].
|
||||||
open: Option<OpenIndex>,
|
open: Option<OpenIndex>,
|
||||||
|
|
@ -401,11 +345,7 @@ impl Worker {
|
||||||
fn run(mut self) {
|
fn run(mut self) {
|
||||||
loop {
|
loop {
|
||||||
let first = match self.req_rx.recv_timeout(self.idle_release) {
|
let first = match self.req_rx.recv_timeout(self.idle_release) {
|
||||||
Ok(WorkerMsg::ReleaseConnection) => {
|
Ok(req) => req,
|
||||||
self.release();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Ok(WorkerMsg::Search(req)) => req,
|
|
||||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||||
// Dropping the connection frees `PRAGMAS_SEARCH`'s 32 MiB
|
// Dropping the connection frees `PRAGMAS_SEARCH`'s 32 MiB
|
||||||
// page cache to glibc, which parks it in an arena rather
|
// page cache to glibc, which parks it in an arena rather
|
||||||
|
|
@ -426,19 +366,10 @@ impl Worker {
|
||||||
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
Err(mpsc::RecvTimeoutError::Disconnected) => return,
|
||||||
};
|
};
|
||||||
// A fast typist queues several requests; only the newest one
|
// A fast typist queues several requests; only the newest one
|
||||||
// matters. A release queued behind them is *not* superseded — it
|
// matters.
|
||||||
// is the one message that must not be coalesced away, so it is
|
|
||||||
// handled where it is found. Whether the search below still runs
|
|
||||||
// is then the generation check's business:
|
|
||||||
// `release_connection` cancels before it sends, so anything
|
|
||||||
// enqueued ahead of the release is stale and drops out rather than
|
|
||||||
// reopening the connection that was just let go of.
|
|
||||||
let mut req = first;
|
let mut req = first;
|
||||||
while let Ok(newer) = self.req_rx.try_recv() {
|
while let Ok(newer) = self.req_rx.try_recv() {
|
||||||
match newer {
|
req = newer;
|
||||||
WorkerMsg::Search(newer) => req = newer,
|
|
||||||
WorkerMsg::ReleaseConnection => self.release(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if req.generation != self.latest_gen.load(Ordering::SeqCst) {
|
if req.generation != self.latest_gen.load(Ordering::SeqCst) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -447,19 +378,6 @@ impl Worker {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the held connection and tell whoever asked.
|
|
||||||
///
|
|
||||||
/// The same trim `run`'s idle path does, for the same reason: glibc parks
|
|
||||||
/// `PRAGMAS_SEARCH`'s page cache in an arena rather than returning it.
|
|
||||||
fn release(&mut self) {
|
|
||||||
if self.open.take().is_some() {
|
|
||||||
crate::platform::release_free_heap();
|
|
||||||
}
|
|
||||||
if let Some(ack) = crate::lock_ok(&self.release_ack).take() {
|
|
||||||
let _ = ack.send(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Take the connection to run this request on, reopening if the one held
|
/// Take the connection to run this request on, reopening if the one held
|
||||||
/// cannot be reused. Reuse is what keeps
|
/// cannot be reused. Reuse is what keeps
|
||||||
/// [`crate::db::schema::PRAGMAS_SEARCH`]'s page cache warm across
|
/// [`crate::db::schema::PRAGMAS_SEARCH`]'s page cache warm across
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
//! Turning "something that must be present" into a SQL narrowing.
|
|
||||||
//!
|
|
||||||
//! Three of the cascade's passes would otherwise read the whole index on every
|
|
||||||
//! keystroke: the fuzzy full-text pass decompresses every stored document, and
|
|
||||||
//! the two `regex:` passes run the user's regex over every name, every path and
|
|
||||||
//! every document. In each case there is a set of literal strings of which **at
|
|
||||||
//! least one must occur** in anything the pass can accept, and that set is
|
|
||||||
//! enough to make the database do the rejecting instead.
|
|
||||||
//!
|
|
||||||
//! Where the sets come from differs; what is done with them does not, which is
|
|
||||||
//! why they meet here:
|
|
||||||
//!
|
|
||||||
//! * the fuzzy pass splits its term into `k + 1` chunks, at most `k` of which
|
|
||||||
//! an in-budget match can damage — see
|
|
||||||
//! [`crate::search::fuzzy::pigeonhole_chunks`];
|
|
||||||
//! * a `regex:` query hands its pattern to the same literal analysis the regex
|
|
||||||
//! engine uses to build its own prefilter — see
|
|
||||||
//! [`crate::query::pattern::RegexQuery`].
|
|
||||||
//!
|
|
||||||
//! # The one rule
|
|
||||||
//!
|
|
||||||
//! A prefilter must be a **superset** of what the pass accepts. It may admit
|
|
||||||
//! rows the pass then rejects — every candidate is verified afterwards exactly
|
|
||||||
//! as before, so ranking and results do not change, only how many rows are
|
|
||||||
//! looked at. It must never exclude a row the pass would have accepted, because
|
|
||||||
//! that failure has no symptom: the file simply stops appearing, and no error
|
|
||||||
//! is raised anywhere.
|
|
||||||
//!
|
|
||||||
//! Everything below is guards in service of that rule.
|
|
||||||
|
|
||||||
use rusqlite::types::Value;
|
|
||||||
|
|
||||||
use crate::query::translator::{escape_like, quote_phrase};
|
|
||||||
|
|
||||||
/// Characters in the smallest unit the FTS5 trigram index can be queried for.
|
|
||||||
///
|
|
||||||
/// A phrase shorter than this matches no token at all, so a prefilter built
|
|
||||||
/// from one would return the empty set rather than a superset — the exact
|
|
||||||
/// failure the module note forbids. It bounds [`Required::fts_expr`] only:
|
|
||||||
/// `LIKE` has no such floor, which is why the two predicates guard separately.
|
|
||||||
pub const TRIGRAM_FLOOR: usize = 3;
|
|
||||||
|
|
||||||
/// Most literals worth OR-ing together.
|
|
||||||
///
|
|
||||||
/// A case-insensitive pattern expands combinatorially — `(?i)FOO` extracts as
|
|
||||||
/// eight literals — and each one costs a term in the MATCH expression or two
|
|
||||||
/// `LIKE`s per row. Past some width the filter stops being cheaper than the
|
|
||||||
/// scan it replaces, and falling back to the scan is always correct.
|
|
||||||
const MAX_LITERALS: usize = 32;
|
|
||||||
|
|
||||||
/// Literals of which at least one occurs in anything the query can match.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Required(Vec<String>);
|
|
||||||
|
|
||||||
impl Required {
|
|
||||||
/// `None` when the set cannot constrain anything: empty, too wide to be
|
|
||||||
/// worth it, or containing an empty literal.
|
|
||||||
///
|
|
||||||
/// The empty-literal case is the one that matters. A literal set containing
|
|
||||||
/// `""` says "a match may begin with nothing", which is not a constraint at
|
|
||||||
/// all — building a filter from it would narrow to rows containing the
|
|
||||||
/// empty string, which is a statement SQL is entitled to answer any way it
|
|
||||||
/// likes. Callers see `None` and scan.
|
|
||||||
pub fn new(literals: Vec<String>) -> Option<Required> {
|
|
||||||
if literals.is_empty() || literals.len() > MAX_LITERALS {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if literals.iter().any(|l| l.is_empty()) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(Required(literals))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn literals(&self) -> &[String] {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `searchabletext MATCH` expression: `(text: "a" OR text: "b" …)`.
|
|
||||||
///
|
|
||||||
/// `None` when any literal is shorter than [`TRIGRAM_FLOOR`] **characters**.
|
|
||||||
/// Characters, not bytes: `café` is five bytes and four characters, and
|
|
||||||
/// `日本` is six bytes and two — the tokenizer indexes character triples, so
|
|
||||||
/// a byte-length test would admit a phrase that matches no token.
|
|
||||||
///
|
|
||||||
/// Every literal goes through [`quote_phrase`], which is what makes this
|
|
||||||
/// safe for text the user typed: a literal can contain `"`, `*`, `:`,
|
|
||||||
/// `NEAR` and the rest of FTS5's syntax, and unquoted that is a syntax
|
|
||||||
/// error rather than a search.
|
|
||||||
///
|
|
||||||
/// The index folds case and strips diacritics (`remove_diacritics 1`), so
|
|
||||||
/// it matches *more* than the literal as written. That direction is the
|
|
||||||
/// harmless one.
|
|
||||||
pub fn fts_expr(&self) -> Option<String> {
|
|
||||||
if self
|
|
||||||
.0
|
|
||||||
.iter()
|
|
||||||
.any(|l| l.chars().count() < TRIGRAM_FLOOR)
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
Some(format!(
|
|
||||||
"({})",
|
|
||||||
self.0
|
|
||||||
.iter()
|
|
||||||
.map(|l| format!("text: {}", quote_phrase(l)))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" OR ")
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A predicate over the `files` columns, plus the values it binds:
|
|
||||||
/// `(f.name LIKE ? OR f.parent LIKE ? OR …)`.
|
|
||||||
///
|
|
||||||
/// `None` when any literal contains a path separator.
|
|
||||||
///
|
|
||||||
/// # Why the separator matters
|
|
||||||
///
|
|
||||||
/// There is no `path` column — a file's path is `parent || name` — so a
|
|
||||||
/// literal has to be looked for in the two columns separately. An
|
|
||||||
/// occurrence in the concatenation lies wholly inside `parent`, wholly
|
|
||||||
/// inside `name`, or spans the join. A spanning occurrence necessarily
|
|
||||||
/// covers the byte before the boundary, and that byte is `parent`'s last,
|
|
||||||
/// which is always a separator (see
|
|
||||||
/// [`crate::file_handling::dir_to_db_parent`]). So a literal with no
|
|
||||||
/// separator in it cannot span the join and the two-column test sees it
|
|
||||||
/// wherever it is — while a literal *with* one might sit exactly across the
|
|
||||||
/// boundary, be invisible to both `LIKE`s, and take its row with it.
|
|
||||||
///
|
|
||||||
/// All-or-nothing on purpose: the set is an OR, so a row whose only present
|
|
||||||
/// literal is the untestable one would be dropped. One bad literal
|
|
||||||
/// therefore disqualifies the whole predicate rather than being skipped.
|
|
||||||
///
|
|
||||||
/// No trigram floor here — `LIKE '%ab%'` is a perfectly good filter.
|
|
||||||
pub fn like_predicate(&self) -> Option<(String, Vec<Value>)> {
|
|
||||||
if self
|
|
||||||
.0
|
|
||||||
.iter()
|
|
||||||
.any(|l| l.contains(std::path::MAIN_SEPARATOR))
|
|
||||||
{
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut clauses = Vec::with_capacity(self.0.len());
|
|
||||||
let mut params = Vec::with_capacity(self.0.len() * 2);
|
|
||||||
for literal in &self.0 {
|
|
||||||
clauses.push("f.name LIKE ? ESCAPE '\\' OR f.parent LIKE ? ESCAPE '\\'");
|
|
||||||
let pattern = format!("%{}%", escape_like(literal));
|
|
||||||
params.push(Value::Text(pattern.clone()));
|
|
||||||
params.push(Value::Text(pattern));
|
|
||||||
}
|
|
||||||
Some((format!("({})", clauses.join(" OR ")), params))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn req(lits: &[&str]) -> Option<Required> {
|
|
||||||
Required::new(lits.iter().map(|s| s.to_string()).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_set_that_cannot_constrain_is_rejected() {
|
|
||||||
assert!(req(&[]).is_none(), "nothing to filter on");
|
|
||||||
assert!(req(&["abc", ""]).is_none(), "an empty literal is no constraint");
|
|
||||||
let wide: Vec<String> = (0..MAX_LITERALS + 1).map(|i| format!("lit{i}")).collect();
|
|
||||||
assert!(Required::new(wide).is_none(), "too wide to be worth it");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_trigram_floor_counts_characters_not_bytes() {
|
|
||||||
// Four characters, five bytes: usable.
|
|
||||||
assert!(req(&["café"]).unwrap().fts_expr().is_some());
|
|
||||||
// Two characters, six bytes: a byte test would wrongly admit this.
|
|
||||||
assert!(req(&["日本"]).unwrap().fts_expr().is_none());
|
|
||||||
assert!(req(&["ab"]).unwrap().fts_expr().is_none());
|
|
||||||
assert!(req(&["abc", "de"]).unwrap().fts_expr().is_none(), "all of them");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn fts5_syntax_in_a_literal_is_quoted_inert() {
|
|
||||||
let expr = req(&["a\"b", "NEAR", "x*y"]).unwrap().fts_expr().unwrap();
|
|
||||||
// The embedded quote is doubled, which is FTS5's own escape.
|
|
||||||
assert!(expr.contains(r#""a""b""#), "{expr}");
|
|
||||||
assert!(expr.contains(r#""NEAR""#), "{expr}");
|
|
||||||
assert!(expr.contains(r#""x*y""#), "{expr}");
|
|
||||||
assert_eq!(expr.matches(" OR ").count(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_literal_with_a_separator_disqualifies_the_like_predicate() {
|
|
||||||
let sep = std::path::MAIN_SEPARATOR;
|
|
||||||
assert!(req(&["abc"]).unwrap().like_predicate().is_some());
|
|
||||||
assert!(
|
|
||||||
req(&["abc", &format!("d{sep}e")])
|
|
||||||
.unwrap()
|
|
||||||
.like_predicate()
|
|
||||||
.is_none(),
|
|
||||||
"one untestable literal disqualifies the whole OR"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn like_metacharacters_in_a_literal_stay_literal() {
|
|
||||||
let (sql, params) = req(&["100%_x"]).unwrap().like_predicate().unwrap();
|
|
||||||
assert_eq!(params.len(), 2, "one literal, bound to both columns");
|
|
||||||
assert!(sql.contains("ESCAPE '\\'"));
|
|
||||||
assert!(
|
|
||||||
matches!(¶ms[0], Value::Text(t) if t == "%100\\%\\_x%"),
|
|
||||||
"{:?}",
|
|
||||||
params[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn several_literals_become_one_or_over_both_columns() {
|
|
||||||
let (sql, params) = req(&["abc", "def"]).unwrap().like_predicate().unwrap();
|
|
||||||
assert_eq!(params.len(), 4);
|
|
||||||
assert_eq!(sql.matches("f.name LIKE").count(), 2);
|
|
||||||
assert_eq!(sql.matches("f.parent LIKE").count(), 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -55,102 +55,35 @@ impl Snippet {
|
||||||
/// [`extract`] against a haystack the caller has already ASCII-folded.
|
/// [`extract`] against a haystack the caller has already ASCII-folded.
|
||||||
/// `folded` must be `text.to_ascii_lowercase()` — the fold is byte-length
|
/// `folded` must be `text.to_ascii_lowercase()` — the fold is byte-length
|
||||||
/// preserving, which is what lets offsets found in it slice the original.
|
/// preserving, which is what lets offsets found in it slice the original.
|
||||||
///
|
pub fn extract_folded(text: &str, folded: &str, terms: &[&str], opts: &Options) -> Snippet {
|
||||||
/// Returns the window together with **how many occurrences it found**, before
|
|
||||||
/// touching ranges are coalesced. For a single term that number is exactly
|
|
||||||
/// what [`count_occurrences`] against `folded` would return, which is what
|
|
||||||
/// lets the full-text pass take its case-insensitive count from here instead
|
|
||||||
/// of sweeping the body a second time to compute it.
|
|
||||||
///
|
|
||||||
/// Blank terms are dropped, but a term that merely *has* surrounding
|
|
||||||
/// whitespace is searched as given: trimming it here would count and
|
|
||||||
/// highlight a different string than the pattern's own counters do.
|
|
||||||
pub fn extract_folded(
|
|
||||||
text: &str,
|
|
||||||
folded: &str,
|
|
||||||
terms: &[&str],
|
|
||||||
opts: &Options,
|
|
||||||
) -> (Snippet, usize) {
|
|
||||||
debug_assert_eq!(folded.len(), text.len(), "ASCII folding preserves length");
|
debug_assert_eq!(folded.len(), text.len(), "ASCII folding preserves length");
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return (Snippet::empty(), 0);
|
return Snippet::empty();
|
||||||
}
|
}
|
||||||
let effective_terms: Vec<&str> = terms
|
let effective_terms: Vec<&str> = terms
|
||||||
.iter()
|
.iter()
|
||||||
.copied()
|
.map(|t| t.trim())
|
||||||
.filter(|t| !t.trim().is_empty())
|
.filter(|t| !t.is_empty())
|
||||||
.collect();
|
.collect();
|
||||||
if effective_terms.is_empty() {
|
if effective_terms.is_empty() {
|
||||||
return (head_window(text, opts.approx_chars), 0);
|
return head_window(text, opts.approx_chars);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `memmem` rather than `str::match_indices`: both find non-overlapping
|
// `memmem` rather than `str::match_indices`: both find non-overlapping
|
||||||
// occurrences, but std's Two-Way searcher has no vector prefilter and a
|
// occurrences, but std's Two-Way searcher has no vector prefilter and a
|
||||||
// full-text row scans a whole document body. See `benches/search.rs`,
|
// full-text row scans a whole document body. See `benches/search.rs`,
|
||||||
// group `substring`.
|
// group `substring`.
|
||||||
// The count has to see every occurrence, so the walk runs to the end of the
|
|
||||||
// body either way. What does *not* have to happen is keeping them: the
|
|
||||||
// window is fixed by the first match, and everything starting past its
|
|
||||||
// right edge is discarded a few lines below. Storing them all meant a `Vec`
|
|
||||||
// proportional to the match count and then a sort over it — for a term
|
|
||||||
// occurring thousands of times in one file, which is an ordinary minified
|
|
||||||
// bundle or log, that is hundreds of kilobytes and an O(n log n) sort per
|
|
||||||
// candidate row, to render a 600-byte window.
|
|
||||||
//
|
|
||||||
// Bounded only for a single term, which is every caller the cascade makes
|
|
||||||
// (`cascade::text_snippet` passes one). With several, two different terms
|
|
||||||
// can coalesce across the edge and no single-pass bound sees it, so they
|
|
||||||
// are collected in full rather than approximately.
|
|
||||||
let pre_pad = opts.approx_chars / 3;
|
|
||||||
let bounded = effective_terms.len() == 1;
|
|
||||||
let mut matches: Vec<(usize, usize)> = Vec::new();
|
let mut matches: Vec<(usize, usize)> = Vec::new();
|
||||||
let mut found = 0usize;
|
|
||||||
// The right edge, once the first match has fixed it. Computed exactly as
|
|
||||||
// the window is computed below, so "kept" and "rendered" cannot disagree.
|
|
||||||
let mut keep_below: Option<usize> = None;
|
|
||||||
|
|
||||||
for term in &effective_terms {
|
for term in &effective_terms {
|
||||||
// Borrow when the term is already folded, which is the case for every
|
let pattern = term.to_ascii_lowercase();
|
||||||
// call the search cascade makes: this runs once per candidate row, and
|
matches.extend(
|
||||||
// an unconditional `to_ascii_lowercase` is an allocation per row to
|
memchr::memmem::find_iter(folded.as_bytes(), pattern.as_bytes())
|
||||||
// rebuild a string the pattern already holds.
|
.map(|at| (at, at + pattern.len())),
|
||||||
let pattern: std::borrow::Cow<'_, str> = if term.bytes().any(|b| b.is_ascii_uppercase()) {
|
);
|
||||||
std::borrow::Cow::Owned(term.to_ascii_lowercase())
|
|
||||||
} else {
|
|
||||||
std::borrow::Cow::Borrowed(*term)
|
|
||||||
};
|
|
||||||
// The end of the last kept match, so a chain of touching occurrences
|
|
||||||
// (`abab…` for term `ab`) that starts inside the window and continues
|
|
||||||
// past it stays intact — `coalesce_overlapping` merges those, and the
|
|
||||||
// expansion step below is entitled to follow the merged range out.
|
|
||||||
let mut chain_end = 0usize;
|
|
||||||
for at in memchr::memmem::find_iter(folded.as_bytes(), pattern.as_bytes()) {
|
|
||||||
// Before any dropping: this is the occurrence count, and two
|
|
||||||
// occurrences that happen to abut are two hits for ranking even
|
|
||||||
// though they are one highlight for painting.
|
|
||||||
found += 1;
|
|
||||||
if bounded {
|
|
||||||
let bound = *keep_below.get_or_insert_with(|| {
|
|
||||||
let mut end = (at.saturating_sub(pre_pad) + opts.approx_chars).min(text.len());
|
|
||||||
while end < text.len() && !text.is_char_boundary(end) {
|
|
||||||
end += 1;
|
|
||||||
}
|
|
||||||
end
|
|
||||||
});
|
|
||||||
if at >= bound && at > chain_end {
|
|
||||||
// Past the edge and not chained to anything kept. Keep
|
|
||||||
// counting — that is the whole rest of the walk — but stop
|
|
||||||
// storing.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chain_end = at + pattern.len();
|
|
||||||
matches.push((at, chain_end));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches.is_empty() {
|
if matches.is_empty() {
|
||||||
return (head_window(text, opts.approx_chars), 0);
|
return head_window(text, opts.approx_chars);
|
||||||
}
|
}
|
||||||
|
|
||||||
matches.sort_by_key(|(a, _)| *a);
|
matches.sort_by_key(|(a, _)| *a);
|
||||||
|
|
@ -159,6 +92,7 @@ pub fn extract_folded(
|
||||||
// Pick the window. Start a third of the budget before the first match
|
// Pick the window. Start a third of the budget before the first match
|
||||||
// so the hit isn't pinned to the left edge; round both ends to char
|
// so the hit isn't pinned to the left edge; round both ends to char
|
||||||
// boundaries so we never slice a multi-byte UTF-8 sequence.
|
// boundaries so we never slice a multi-byte UTF-8 sequence.
|
||||||
|
let pre_pad = opts.approx_chars / 3;
|
||||||
let mut win_start = matches[0].0.saturating_sub(pre_pad);
|
let mut win_start = matches[0].0.saturating_sub(pre_pad);
|
||||||
let mut win_end = (win_start + opts.approx_chars).min(text.len());
|
let mut win_end = (win_start + opts.approx_chars).min(text.len());
|
||||||
while win_start > 0 && !text.is_char_boundary(win_start) {
|
while win_start > 0 && !text.is_char_boundary(win_start) {
|
||||||
|
|
@ -190,15 +124,12 @@ pub fn extract_folded(
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
(
|
Snippet {
|
||||||
Snippet {
|
window: text[win_start..win_end].to_string(),
|
||||||
window: text[win_start..win_end].to_string(),
|
ranges,
|
||||||
ranges,
|
truncated_start: win_start > 0,
|
||||||
truncated_start: win_start > 0,
|
truncated_end: win_end < text.len(),
|
||||||
truncated_end: win_end < text.len(),
|
}
|
||||||
},
|
|
||||||
found,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clamp `range` into `text` and widen it to the nearest char boundaries.
|
/// Clamp `range` into `text` and widen it to the nearest char boundaries.
|
||||||
|
|
@ -344,12 +275,6 @@ mod tests {
|
||||||
/// Production always holds a fold buffer already, so the wrapper earned
|
/// Production always holds a fold buffer already, so the wrapper earned
|
||||||
/// nothing; folding here keeps its coverage of the window logic.
|
/// nothing; folding here keeps its coverage of the window logic.
|
||||||
fn extract(text: &str, terms: &[&str], opts: &Options) -> Snippet {
|
fn extract(text: &str, terms: &[&str], opts: &Options) -> Snippet {
|
||||||
extract_folded(text, &text.to_ascii_lowercase(), terms, opts).0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The occurrence count alongside the window — the half the full-text
|
|
||||||
/// pass consumes.
|
|
||||||
fn extract_counted(text: &str, terms: &[&str], opts: &Options) -> (Snippet, usize) {
|
|
||||||
extract_folded(text, &text.to_ascii_lowercase(), terms, opts)
|
extract_folded(text, &text.to_ascii_lowercase(), terms, opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,88 +282,6 @@ mod tests {
|
||||||
Options { approx_chars: 40 }
|
Options { approx_chars: 40 }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The count `extract_folded` hands back is what the full-text pass ranks
|
|
||||||
/// on, so it must be occurrences — not the highlights they coalesce into.
|
|
||||||
#[test]
|
|
||||||
fn extract_folded_counts_occurrences_not_ranges() {
|
|
||||||
let text = "abab and ab";
|
|
||||||
let (s, n) = extract_counted(text, &["ab"], &opts_small());
|
|
||||||
assert_eq!(n, 3, "three occurrences");
|
|
||||||
assert_eq!(
|
|
||||||
n,
|
|
||||||
count_occurrences(text, "ab", true),
|
|
||||||
"must agree with the counter the pass used to call"
|
|
||||||
);
|
|
||||||
assert_eq!(s.ranges.len(), 2, "the abutting pair paints as one range");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The pass takes `count_folded`'s answer from here now; anything that
|
|
||||||
/// made the two disagree would silently change which rows survive.
|
|
||||||
#[test]
|
|
||||||
fn extract_folded_count_agrees_with_count_occurrences() {
|
|
||||||
let cases: &[(&str, &str)] = &[
|
|
||||||
("no hits at all", "zzz"),
|
|
||||||
("one hit here", "hit"),
|
|
||||||
("Hit hit HIT", "hit"),
|
|
||||||
("aaaa", "aa"),
|
|
||||||
("", "x"),
|
|
||||||
("short", "much longer than the haystack"),
|
|
||||||
("ünïcode ünïcode", "ünïcode"),
|
|
||||||
];
|
|
||||||
for (text, term) in cases {
|
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
let (_, n) = extract_folded(text, &folded, &[term], &opts_small());
|
|
||||||
assert_eq!(
|
|
||||||
n,
|
|
||||||
count_occurrences(&folded, &term.to_ascii_lowercase(), true),
|
|
||||||
"count mismatch for {:?} in {:?}",
|
|
||||||
term,
|
|
||||||
text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An uppercase needle takes the owning branch of the fold; a needle that
|
|
||||||
/// is already folded takes the borrowing one. Both must find the same
|
|
||||||
/// matches, since the whole point of the borrow is that it changes
|
|
||||||
/// nothing but the allocation.
|
|
||||||
#[test]
|
|
||||||
fn extract_folded_needle_case_does_not_change_matches() {
|
|
||||||
let text = "The Needle and the needle";
|
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
let (upper, n_upper) = extract_folded(text, &folded, &["Needle"], &opts_small());
|
|
||||||
let (lower, n_lower) = extract_folded(text, &folded, &["needle"], &opts_small());
|
|
||||||
assert_eq!(n_upper, 2);
|
|
||||||
assert_eq!(n_upper, n_lower);
|
|
||||||
assert_eq!(upper.ranges, lower.ranges);
|
|
||||||
assert_eq!(upper.window, lower.window);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A blank term is dropped rather than searched: an empty needle matches
|
|
||||||
/// at every byte offset, which would rank a document by its length.
|
|
||||||
#[test]
|
|
||||||
fn extract_folded_ignores_blank_terms() {
|
|
||||||
let text = "some text";
|
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
for term in ["", " ", "\t"] {
|
|
||||||
let (s, n) = extract_folded(text, &folded, &[term], &opts_small());
|
|
||||||
assert_eq!(n, 0, "blank term {:?} counted", term);
|
|
||||||
assert!(s.ranges.is_empty(), "blank term {:?} highlighted", term);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A term with surrounding space is searched as given. It used to be
|
|
||||||
/// trimmed, which counted and highlighted a different string than the
|
|
||||||
/// pattern's own counters did.
|
|
||||||
#[test]
|
|
||||||
fn extract_folded_does_not_trim_a_padded_term() {
|
|
||||||
let text = "needle needlework";
|
|
||||||
let folded = text.to_ascii_lowercase();
|
|
||||||
let (_, n) = extract_folded(text, &folded, &["needle "], &opts_small());
|
|
||||||
assert_eq!(n, 1, "only the occurrence followed by a space");
|
|
||||||
assert_eq!(n, count_occurrences(&folded, "needle ", true));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every range must be in-bounds, ordered, non-overlapping, and sit on
|
/// Every range must be in-bounds, ordered, non-overlapping, and sit on
|
||||||
/// char boundaries — the contract egui's LayoutJob sections rely on.
|
/// char boundaries — the contract egui's LayoutJob sections rely on.
|
||||||
fn assert_ranges_valid(s: &Snippet) {
|
fn assert_ranges_valid(s: &Snippet) {
|
||||||
|
|
@ -456,83 +299,6 @@ mod tests {
|
||||||
s.ranges.iter().map(|&(a, b)| &s.window[a..b]).collect()
|
s.ranges.iter().map(|&(a, b)| &s.window[a..b]).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Occurrences past the window's right edge are counted but not kept, and
|
|
||||||
/// that has to be invisible from the outside.
|
|
||||||
///
|
|
||||||
/// The count is what the full-text pass ranks on, so it must still see the
|
|
||||||
/// whole body; the ranges are what gets painted, so they must still be
|
|
||||||
/// exactly the occurrences inside the window. A bound that leaked into
|
|
||||||
/// either would be a ranking change or a missing highlight.
|
|
||||||
#[test]
|
|
||||||
fn occurrences_past_the_window_are_counted_but_not_kept() {
|
|
||||||
// 400 occurrences, evenly spread, far more than a 40-byte window holds.
|
|
||||||
let unit = "needle filler filler ";
|
|
||||||
let text = unit.repeat(400);
|
|
||||||
let (snip, found) = extract_counted(&text, &["needle"], &opts_small());
|
|
||||||
|
|
||||||
assert_eq!(found, 400, "every occurrence must still be counted");
|
|
||||||
assert_ranges_valid(&snip);
|
|
||||||
assert!(!snip.ranges.is_empty());
|
|
||||||
assert!(snip.truncated_end, "there is a great deal more body");
|
|
||||||
|
|
||||||
// Every range is a real occurrence, and every occurrence that falls
|
|
||||||
// inside the window has a range.
|
|
||||||
for &(a, b) in &snip.ranges {
|
|
||||||
assert_eq!(&snip.window[a..b], "needle");
|
|
||||||
}
|
|
||||||
let win_at = text.find(&snip.window).expect("the window is a slice of the text");
|
|
||||||
let expected = memchr::memmem::find_iter(text.as_bytes(), b"needle")
|
|
||||||
.filter(|at| *at >= win_at && *at < win_at + snip.window.len())
|
|
||||||
.count();
|
|
||||||
assert_eq!(
|
|
||||||
snip.ranges.len(),
|
|
||||||
expected,
|
|
||||||
"every occurrence inside the window must be marked"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The case the chain rule exists for: touching occurrences coalesce into
|
|
||||||
/// one range, and a chain that starts inside the window can run past its
|
|
||||||
/// right edge. Dropping the moment the edge is crossed would cut the
|
|
||||||
/// highlight short.
|
|
||||||
#[test]
|
|
||||||
fn a_coalescing_chain_is_not_cut_at_the_window_edge() {
|
|
||||||
// One unbroken run of `ab`, far longer than the window.
|
|
||||||
let text = "ab".repeat(400);
|
|
||||||
let (snip, found) = extract_counted(&text, &["ab"], &opts_small());
|
|
||||||
|
|
||||||
assert_eq!(found, 400, "non-overlapping occurrences, all counted");
|
|
||||||
assert_ranges_valid(&snip);
|
|
||||||
assert_eq!(snip.ranges.len(), 1, "one chain, one highlight");
|
|
||||||
|
|
||||||
// The load-bearing assertion, and it has to be about the window's
|
|
||||||
// *size*. The whole run coalesces into one range, and the expansion
|
|
||||||
// step then grows the window to cover it — so an unbroken chain
|
|
||||||
// legitimately produces a window the length of the text, not the
|
|
||||||
// 40-byte budget. Dropping the chain rule cuts the run at the budget
|
|
||||||
// and yields a 40-byte window instead, which asserting only
|
|
||||||
// "range == whole window" cannot tell apart, because both are.
|
|
||||||
assert_eq!(
|
|
||||||
snip.window.len(),
|
|
||||||
text.len(),
|
|
||||||
"the window must grow to cover the coalesced run"
|
|
||||||
);
|
|
||||||
assert!(!snip.truncated_end, "nothing is left past a full-length window");
|
|
||||||
assert_eq!(snip.ranges[0], (0, text.len()), "one highlight over the lot");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Several terms disable the bound, because two different terms can
|
|
||||||
/// coalesce across the edge and a per-term walk cannot see it. Pin that the
|
|
||||||
/// multi-term path still produces the full, correct answer.
|
|
||||||
#[test]
|
|
||||||
fn several_terms_still_coalesce_across_the_edge() {
|
|
||||||
let text = format!("{}{}", "x".repeat(10), "abc".repeat(200));
|
|
||||||
let (snip, found) = extract_counted(&text, &["ab", "bc"], &opts_small());
|
|
||||||
assert_eq!(found, 400, "200 of each term");
|
|
||||||
assert_ranges_valid(&snip);
|
|
||||||
assert_eq!(snip.ranges.len(), 1, "the two terms interleave into one run");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_text_returns_empty() {
|
fn empty_text_returns_empty() {
|
||||||
let s = extract("", &["foo"], &Options::default());
|
let s = extract("", &["foo"], &Options::default());
|
||||||
|
|
|
||||||
|
|
@ -121,56 +121,6 @@ pub fn touch(path: &std::path::Path, body: &[u8]) {
|
||||||
std::fs::write(path, body).expect("write file");
|
std::fs::write(path, body).expect("write file");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A filename that is legal on disk but cannot round-trip through the index,
|
|
||||||
/// spelled so that `to_string_lossy` yields exactly `{stem}\u{FFFD}{suffix}` —
|
|
||||||
/// which is itself a perfectly ordinary filename, and so a name a *different*
|
|
||||||
/// file can really have. That collision is what the screens in
|
|
||||||
/// `crate::walk::read_directory` and `crate::watcher` exist to prevent, and
|
|
||||||
/// pairing this with [`lossy_twin`] is how the tests reproduce it.
|
|
||||||
///
|
|
||||||
/// The two platforms fail in different ways and both are real:
|
|
||||||
///
|
|
||||||
/// * On Unix an `OsStr` is arbitrary bytes, so any invalid UTF-8 byte does it.
|
|
||||||
/// `0xFF` can never appear in well-formed UTF-8.
|
|
||||||
/// * On Windows a path is UTF-16 code units and NTFS does not check that they
|
|
||||||
/// are well-*formed*, so an unpaired surrogate is storable. Rust models this
|
|
||||||
/// with WTF-8, and `to_str()` returns `None` for precisely that case. Far
|
|
||||||
/// from theoretical: WSL's DrvFs encodes non-UTF-8 Linux names this way by
|
|
||||||
/// design, and Samba shares of Linux servers produce them from legacy
|
|
||||||
/// encodings.
|
|
||||||
///
|
|
||||||
/// Some filesystems (FAT, exFAT, some network redirectors) refuse the name —
|
|
||||||
/// tests that put one on disk must tolerate the creation failing rather than
|
|
||||||
/// asserting on it.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn unrepresentable_name(stem: &str, suffix: &str) -> std::ffi::OsString {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::ffi::OsStringExt;
|
|
||||||
let mut bytes = stem.as_bytes().to_vec();
|
|
||||||
bytes.push(0xFF);
|
|
||||||
bytes.extend_from_slice(suffix.as_bytes());
|
|
||||||
std::ffi::OsString::from_vec(bytes)
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use std::os::windows::ffi::OsStringExt;
|
|
||||||
let mut units: Vec<u16> = stem.encode_utf16().collect();
|
|
||||||
// A high surrogate with nothing after it to pair with.
|
|
||||||
units.push(0xD800);
|
|
||||||
units.extend(suffix.encode_utf16());
|
|
||||||
std::ffi::OsString::from_wide(&units)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The name [`unrepresentable_name`] collapses to under `to_string_lossy`, as
|
|
||||||
/// a name that is genuinely representable — so a test can put both on disk and
|
|
||||||
/// assert the real one survives what happens to the other.
|
|
||||||
#[doc(hidden)]
|
|
||||||
pub fn lossy_twin(stem: &str, suffix: &str) -> String {
|
|
||||||
format!("{}\u{FFFD}{}", stem, suffix)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Power-of-two bucket, so memory-map sizes group by what allocated them
|
/// Power-of-two bucket, so memory-map sizes group by what allocated them
|
||||||
/// rather than by their exact size. Shared by the memory probes.
|
/// rather than by their exact size. Shared by the memory probes.
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
|
|
@ -212,32 +162,6 @@ mod tests {
|
||||||
assert_eq!(std::fs::read(&deep).unwrap(), b"hi");
|
assert_eq!(std::fs::read(&deep).unwrap(), b"hi");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The premise every collision test rests on, pinned per platform: the
|
|
||||||
/// name really is unrepresentable, and its lossy image really is a name
|
|
||||||
/// another file could have. If this ever stops holding, those tests would
|
|
||||||
/// silently start asserting nothing.
|
|
||||||
#[test]
|
|
||||||
fn the_unrepresentable_name_collapses_onto_its_twin() {
|
|
||||||
let bad = unrepresentable_name("x", ".txt");
|
|
||||||
assert!(
|
|
||||||
bad.to_str().is_none(),
|
|
||||||
"the name must not be representable: {:?}",
|
|
||||||
bad
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
bad.to_string_lossy(),
|
|
||||||
lossy_twin("x", ".txt"),
|
|
||||||
"the two names must collide under to_string_lossy"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
std::path::Path::new(&lossy_twin("x", ".txt"))
|
|
||||||
.as_os_str()
|
|
||||||
.to_str()
|
|
||||||
.is_some(),
|
|
||||||
"the twin must itself be a perfectly ordinary name"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The sweep runs against a shared temp directory, so what it matches is
|
/// The sweep runs against a shared temp directory, so what it matches is
|
||||||
/// the whole safety argument. `quicksearch-capture` is the one that would
|
/// the whole safety argument. `quicksearch-capture` is the one that would
|
||||||
/// hurt: `packaging/capture.sh` puts a run's screenshots and screencasts
|
/// hurt: `packaging/capture.sh` puts a run's screenshots and screencasts
|
||||||
|
|
|
||||||
|
|
@ -44,13 +44,6 @@
|
||||||
//! as a FAILED row with a reason, the normal shape of head-based
|
//! as a FAILED row with a reason, the normal shape of head-based
|
||||||
//! classification.
|
//! classification.
|
||||||
|
|
||||||
/// How much of a file the charset detector is shown.
|
|
||||||
///
|
|
||||||
/// chardetng scores byte frequencies against a model per encoding; the answer
|
|
||||||
/// stops moving well inside this. Reading further is a second full pass over
|
|
||||||
/// the file for no change in the verdict.
|
|
||||||
const DETECT_PREFIX: usize = 64 * 1024;
|
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
enum TextClass {
|
enum TextClass {
|
||||||
|
|
@ -141,14 +134,7 @@ pub fn decode_text(bytes: Vec<u8>, path: &Path) -> Result<String, String> {
|
||||||
// ISO-2022-JP detection is safe here: the browser caveat about
|
// ISO-2022-JP detection is safe here: the browser caveat about
|
||||||
// it concerns script-running web content, not indexed files.
|
// it concerns script-running web content, not indexed files.
|
||||||
let mut det = chardetng::EncodingDetector::new(chardetng::Iso2022JpDetection::Allow);
|
let mut det = chardetng::EncodingDetector::new(chardetng::Iso2022JpDetection::Allow);
|
||||||
// A prefix, not the whole file. The detector is scoring byte
|
det.feed(&bytes, true);
|
||||||
// frequencies against per-encoding models, and those converge in
|
|
||||||
// kilobytes — feeding it a 200 MiB log costs a full extra pass
|
|
||||||
// over it to reach the same answer. `last` stays false because
|
|
||||||
// there may be more: it only tells the detector this is not the
|
|
||||||
// end of the input, which is exactly right for a prefix.
|
|
||||||
let prefix = &bytes[..bytes.len().min(DETECT_PREFIX)];
|
|
||||||
det.feed(prefix, prefix.len() == bytes.len());
|
|
||||||
// Deny UTF-8: strict UTF-8 was already ruled out, so a UTF-8
|
// Deny UTF-8: strict UTF-8 was already ruled out, so a UTF-8
|
||||||
// guess could only mean malformed UTF-8.
|
// guess could only mean malformed UTF-8.
|
||||||
let enc = det.guess(None, chardetng::Utf8Detection::Deny);
|
let enc = det.guess(None, chardetng::Utf8Detection::Deny);
|
||||||
|
|
@ -159,31 +145,6 @@ pub fn decode_text(bytes: Vec<u8>, path: &Path) -> Result<String, String> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace control characters with `U+FFFD`, borrowing when there are none.
|
|
||||||
///
|
|
||||||
/// Filenames may contain any byte but NUL and the separator, and extracted
|
|
||||||
/// text is whatever was in the file — so both can carry terminal escape
|
|
||||||
/// sequences. Printed raw they rewrite the line, retitle the window, or on a
|
|
||||||
/// terminal with OSC 52 enabled put text of the writer's choosing on the
|
|
||||||
/// user's clipboard. `ls` has scrubbed for this reason for decades.
|
|
||||||
///
|
|
||||||
/// Tab survives: it is a legitimate part of a filename and harmless. So does
|
|
||||||
/// everything above C1 — this is not a general sanitiser, and the point is to
|
|
||||||
/// stay byte-for-byte faithful wherever there is nothing dangerous to remove.
|
|
||||||
pub fn scrub_controls(s: &str) -> std::borrow::Cow<'_, str> {
|
|
||||||
fn dangerous(c: char) -> bool {
|
|
||||||
(c.is_control() && c != '\t') || ('\u{80}'..='\u{9f}').contains(&c)
|
|
||||||
}
|
|
||||||
if !s.chars().any(dangerous) {
|
|
||||||
return std::borrow::Cow::Borrowed(s);
|
|
||||||
}
|
|
||||||
std::borrow::Cow::Owned(
|
|
||||||
s.chars()
|
|
||||||
.map(|c| if dangerous(c) { '\u{fffd}' } else { c })
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -254,38 +215,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detection reads a bounded prefix, so a file far larger than it still
|
|
||||||
/// decodes as the encoding its head implies — and the tail is decoded in
|
|
||||||
/// full regardless, since only the *detector*'s input is bounded.
|
|
||||||
#[test]
|
|
||||||
fn detection_prefix_is_bounded_and_the_tail_still_decodes() {
|
|
||||||
let head = b"Le caf\xe9 pr\xe8s de la fen\xeatre est agr\xe9able en \xe9t\xe9. ";
|
|
||||||
let mut body = Vec::new();
|
|
||||||
while body.len() < DETECT_PREFIX * 3 {
|
|
||||||
body.extend_from_slice(head);
|
|
||||||
}
|
|
||||||
// A marker past the detector's window: it must survive the decode.
|
|
||||||
body.extend_from_slice(b"caf\xe9-tail-marker");
|
|
||||||
let out = decode_text(body, &p()).unwrap();
|
|
||||||
assert!(
|
|
||||||
out.ends_with("café-tail-marker"),
|
|
||||||
"the tail past the detection prefix must still be decoded"
|
|
||||||
);
|
|
||||||
assert!(out.starts_with("Le café près"), "got {:?}", &out[..24]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The prefix bound must not change the verdict for a file smaller than
|
|
||||||
/// it — the whole buffer is still what the detector sees.
|
|
||||||
#[test]
|
|
||||||
fn detection_prefix_leaves_short_files_alone() {
|
|
||||||
let body = b"Le caf\xe9 pr\xe8s de la fen\xeatre est agr\xe9able en \xe9t\xe9.".to_vec();
|
|
||||||
assert!(body.len() < DETECT_PREFIX);
|
|
||||||
assert_eq!(
|
|
||||||
decode_text(body, &p()).unwrap(),
|
|
||||||
"Le café près de la fenêtre est agréable en été."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shift_jis_decodes_but_does_not_sniff() {
|
fn shift_jis_decodes_but_does_not_sniff() {
|
||||||
// "日本語のテキストです。これはシフトJISでエンコードされています。"
|
// "日本語のテキストです。これはシフトJISでエンコードされています。"
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ use sha2::{Digest, Sha256};
|
||||||
use crate::config::{Config, IgnoreSet};
|
use crate::config::{Config, IgnoreSet};
|
||||||
use crate::extract::Registry;
|
use crate::extract::Registry;
|
||||||
use crate::file_handling::{
|
use crate::file_handling::{
|
||||||
classify_by_mtime, classify_for_indexing, dir_to_db_parent, path_to_db_string,
|
classify_by_mtime, classify_for_indexing, path_to_db_string, prepare_file_record,
|
||||||
prepare_file_record, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs,
|
warn_if_unrepresentable, DirRows, FileIndexAction, OwnedNewFile, UnreadableDirs,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod pool;
|
mod pool;
|
||||||
|
|
@ -53,8 +53,7 @@ const NETWORK_THREADS: usize = 16;
|
||||||
/// One file the walk found, with everything the DB writer needs.
|
/// One file the walk found, with everything the DB writer needs.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct WalkedFile {
|
pub struct WalkedFile {
|
||||||
/// Canonical path. The row it keys is `(parent, name)`; see
|
/// Canonical path, and the `files.path` key.
|
||||||
/// [`crate::file_handling::split_db_path`].
|
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub action: FileIndexAction,
|
pub action: FileIndexAction,
|
||||||
/// `None` when there is nothing to write: unchanged, or the record could
|
/// `None` when there is nothing to write: unchanged, or the record could
|
||||||
|
|
@ -165,13 +164,6 @@ struct Ctx {
|
||||||
follow_symlinks: bool,
|
follow_symlinks: bool,
|
||||||
include_hidden: bool,
|
include_hidden: bool,
|
||||||
ignore: IgnoreSet,
|
ignore: IgnoreSet,
|
||||||
/// The index's own files, which this walk must never so much as open.
|
|
||||||
/// See [`crate::file_handling::index_file_set`] for why opening one is
|
|
||||||
/// fatal rather than merely wasteful.
|
|
||||||
///
|
|
||||||
/// Precomputed rather than derived per entry: it is one canonicalize, and
|
|
||||||
/// the alternative is a syscall against every file in the tree.
|
|
||||||
index_files: HashSet<PathBuf>,
|
|
||||||
pruned: PruneCounts,
|
pruned: PruneCounts,
|
||||||
config: Config,
|
config: Config,
|
||||||
registry: Arc<Registry>,
|
registry: Arc<Registry>,
|
||||||
|
|
@ -183,16 +175,9 @@ struct Ctx {
|
||||||
/// count is kept. Reset by [`reset_run_warnings`].
|
/// count is kept. Reset by [`reset_run_warnings`].
|
||||||
static UNREADABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
|
static UNREADABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
|
||||||
|
|
||||||
/// The same, for names that cannot round-trip through the index. Throttled
|
/// Arm this module's per-run warning throttle.
|
||||||
/// because a share can hold thousands of them: one legacy-encoded directory on
|
|
||||||
/// a Samba mount, or a `\\wsl.localhost\` tree, and every entry under it is a
|
|
||||||
/// separate occurrence.
|
|
||||||
static UNREPRESENTABLE_WARNINGS: crate::log::Throttle = crate::log::Throttle::new(20);
|
|
||||||
|
|
||||||
/// Arm this module's per-run warning throttles.
|
|
||||||
pub fn reset_run_warnings() {
|
pub fn reset_run_warnings() {
|
||||||
UNREADABLE_WARNINGS.reset();
|
UNREADABLE_WARNINGS.reset();
|
||||||
UNREPRESENTABLE_WARNINGS.reset();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read one directory, apply the hidden/ignore rules, and split the result:
|
/// Read one directory, apply the hidden/ignore rules, and split the result:
|
||||||
|
|
@ -219,19 +204,7 @@ fn read_directory(
|
||||||
if UNREADABLE_WARNINGS.allow() {
|
if UNREADABLE_WARNINGS.allow() {
|
||||||
crate::log_warn!("cannot read {}: {}", dir.display(), e);
|
crate::log_warn!("cannot read {}: {}", dir.display(), e);
|
||||||
}
|
}
|
||||||
// "Gone" is not "could not look", and only the second one is a
|
ctx.unreadable.record(dir.to_path_buf());
|
||||||
// reason to distrust the walk. A directory deleted while the walk
|
|
||||||
// was in flight — a build tree, a browser cache — is a fact about
|
|
||||||
// the filesystem: its rows *should* fall to the stale sweep, and
|
|
||||||
// recording it here would both spare them and cost this root its
|
|
||||||
// stored walk count, which is what makes every later run pay for
|
|
||||||
// a second `find | wc` traversal of the whole tree.
|
|
||||||
//
|
|
||||||
// The same distinction the coordinator draws in `verb_for`:
|
|
||||||
// `NotFound` is unambiguous, every other errno is not.
|
|
||||||
if e.kind() != std::io::ErrorKind::NotFound {
|
|
||||||
ctx.unreadable.record(dir.to_path_buf());
|
|
||||||
}
|
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -258,49 +231,13 @@ fn read_directory(
|
||||||
};
|
};
|
||||||
|
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
// **The screen for names the index cannot spell, and the only one.**
|
let name = name.to_string_lossy();
|
||||||
//
|
|
||||||
// `files.name` and `files.parent` are TEXT, so a name that is not valid
|
|
||||||
// UTF-8 has no representation there; `to_string_lossy` would give one,
|
|
||||||
// but it is many-to-one, and every use of a path in this walk is a
|
|
||||||
// database *key*. A lossy name collides with the real name of a
|
|
||||||
// different file — U+FFFD is an ordinary filename character — and the
|
|
||||||
// collision is not a cosmetic one: the lossy parent makes the
|
|
||||||
// prefetcher hand this directory another directory's rows, and the diff
|
|
||||||
// below then reports all of them stale. Screening here, before any
|
|
||||||
// string is built, is what keeps that from being possible at all.
|
|
||||||
//
|
|
||||||
// On Unix this is any invalid byte sequence; on Windows it is an
|
|
||||||
// unpaired UTF-16 surrogate, which NTFS stores happily and WSL's DrvFs
|
|
||||||
// emits by design for non-UTF-8 Linux names.
|
|
||||||
//
|
|
||||||
// A directory is pruned whole and at its root: the join below would
|
|
||||||
// carry the bad component into every path beneath it, so nothing under
|
|
||||||
// it could be indexed either way, and stopping here costs one warning
|
|
||||||
// instead of one per descendant.
|
|
||||||
//
|
|
||||||
// Leaving the entry out of `present` is safe, despite the rule above
|
|
||||||
// that every `continue` must be a genuine "not indexable". No stored
|
|
||||||
// row can carry a name that is not valid UTF-8, so this entry has no
|
|
||||||
// row to protect; and a row whose name happens to *equal* the lossy
|
|
||||||
// spelling belongs to some other, representable entry, which this same
|
|
||||||
// listing yields separately and which marks itself present.
|
|
||||||
let Some(name) = name.to_str() else {
|
|
||||||
if UNREPRESENTABLE_WARNINGS.allow() {
|
|
||||||
crate::log_warn!(
|
|
||||||
"Skipping {:?} (name is not valid UTF-8, so it cannot be stored, hashed \
|
|
||||||
or text-indexed)",
|
|
||||||
entry.path()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
// The closure runs only on Windows, where `entry.metadata()` is free —
|
// The closure runs only on Windows, where `entry.metadata()` is free —
|
||||||
// the attributes came back with the directory read, and it reports the
|
// the attributes came back with the directory read, and it reports the
|
||||||
// entry itself rather than a link target.
|
// entry itself rather than a link target.
|
||||||
if !ctx.include_hidden {
|
if !ctx.include_hidden {
|
||||||
if let Some(reason) =
|
if let Some(reason) =
|
||||||
crate::platform::entry_hidden_reason(name, || entry.metadata().ok())
|
crate::platform::entry_hidden_reason(&name, || entry.metadata().ok())
|
||||||
{
|
{
|
||||||
match reason {
|
match reason {
|
||||||
crate::platform::HiddenReason::DotPrefix => {
|
crate::platform::HiddenReason::DotPrefix => {
|
||||||
|
|
@ -323,7 +260,7 @@ fn read_directory(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ctx.ignore.matches_component(name) {
|
if ctx.ignore.matches_component(&name) {
|
||||||
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -332,20 +269,6 @@ fn read_directory(
|
||||||
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The index's own database and sidecars. Not a user preference and
|
|
||||||
// not overridable, because hashing one is not a slow row, it is a
|
|
||||||
// process-wide cancellation of SQLite's locks on a file we are in the
|
|
||||||
// middle of writing — see `file_handling::index_file_set`.
|
|
||||||
//
|
|
||||||
// `continue` rather than a `WalkedFile::skipped`, deliberately: this
|
|
||||||
// leaves the name out of `present`, so any row an earlier run wrote
|
|
||||||
// for the index — before this pruning existed, or from a spell when
|
|
||||||
// `database_path` pointed elsewhere — falls to the stale sweep and is
|
|
||||||
// deleted. `skipped` would keep it forever.
|
|
||||||
if ctx.index_files.contains(&path) {
|
|
||||||
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `file_type` is the cached `d_type` from the directory read.
|
// `file_type` is the cached `d_type` from the directory read.
|
||||||
match entry.file_type() {
|
match entry.file_type() {
|
||||||
|
|
@ -368,33 +291,7 @@ fn read_directory(
|
||||||
// which full-path ignore patterns would never match and
|
// which full-path ignore patterns would never match and
|
||||||
// `seen_dirs` could not dedup against an overlapping root.
|
// `seen_dirs` could not dedup against an overlapping root.
|
||||||
if let Ok(target) = path.canonicalize() {
|
if let Ok(target) = path.canonicalize() {
|
||||||
// The link's own name passed the screen above; the target
|
|
||||||
// is a different path and gets its own. Without this, a
|
|
||||||
// link to an unrepresentable path would be resolved to its
|
|
||||||
// *lossy* spelling — a path naming some other file
|
|
||||||
// entirely, which would then be walked or indexed in its
|
|
||||||
// place.
|
|
||||||
if target.to_str().is_none() {
|
|
||||||
if UNREPRESENTABLE_WARNINGS.allow() {
|
|
||||||
crate::log_warn!(
|
|
||||||
"Skipping {} (its target {:?} is not valid UTF-8, so it cannot \
|
|
||||||
be stored, hashed or text-indexed)",
|
|
||||||
path.display(),
|
|
||||||
target
|
|
||||||
);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let target = PathBuf::from(path_to_db_string(&target));
|
let target = PathBuf::from(path_to_db_string(&target));
|
||||||
// Again on the resolved target: the check above tested the
|
|
||||||
// link's own name, and a symlink pointing at the index
|
|
||||||
// would otherwise walk straight past it into an `open`.
|
|
||||||
// Harmless for a directory target — the set holds only
|
|
||||||
// files — which is why one check covers both arms.
|
|
||||||
if ctx.index_files.contains(&target) {
|
|
||||||
ctx.pruned.ignored.fetch_add(1, Ordering::Relaxed);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match fs::metadata(&target) {
|
match fs::metadata(&target) {
|
||||||
Ok(m) if m.is_dir() => found.push(Found::Dir(target)),
|
Ok(m) if m.is_dir() => found.push(Found::Dir(target)),
|
||||||
// The target's row belongs to its own directory, so
|
// The target's row belongs to its own directory, so
|
||||||
|
|
@ -405,7 +302,7 @@ fn read_directory(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
present.insert(name.to_string());
|
present.insert(name.into_owned());
|
||||||
// `None` on Unix and on any reparse point: see
|
// `None` on Unix and on any reparse point: see
|
||||||
// `entry_cached_metadata`.
|
// `entry_cached_metadata`.
|
||||||
let cached = crate::platform::entry_cached_metadata(|| entry.metadata().ok());
|
let cached = crate::platform::entry_cached_metadata(|| entry.metadata().ok());
|
||||||
|
|
@ -413,7 +310,7 @@ fn read_directory(
|
||||||
}
|
}
|
||||||
// Type unknown: mark it present so an existing row survives.
|
// Type unknown: mark it present so an existing row survives.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
present.insert(name.to_string());
|
present.insert(name.into_owned());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -421,7 +318,7 @@ fn read_directory(
|
||||||
if !unreadable_entry {
|
if !unreadable_entry {
|
||||||
// Rebuild each stored path the way `prepare` does, by joining onto
|
// Rebuild each stored path the way `prepare` does, by joining onto
|
||||||
// the canonical directory, so separators and roots match the
|
// the canonical directory, so separators and roots match the
|
||||||
// stored spelling exactly.
|
// `files.path` spelling exactly.
|
||||||
stale.extend(
|
stale.extend(
|
||||||
rows.keys()
|
rows.keys()
|
||||||
.filter(|name| !present.contains(name.as_str()))
|
.filter(|name| !present.contains(name.as_str()))
|
||||||
|
|
@ -468,23 +365,16 @@ pub fn path_digest(path: &str) -> u128 {
|
||||||
/// see [`crate::platform::metadata_or_stat`].
|
/// see [`crate::platform::metadata_or_stat`].
|
||||||
fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile {
|
fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile {
|
||||||
let PendingFile { path, cached } = file;
|
let PendingFile { path, cached } = file;
|
||||||
// Every route here has already screened the path: a root is screened after
|
|
||||||
// canonicalizing (which can resolve onto a name the config string was
|
|
||||||
// not), `Job::Files` comes from a listing `read_directory` filtered, and
|
|
||||||
// `Job::Alias` from a target the symlink arm checked. That
|
|
||||||
// matters because `path_to_db_string` is lossy, and the string below is
|
|
||||||
// used as a database key *and* hashed into the run's duplicate-visit set —
|
|
||||||
// a lossy one would key another file's row and could consume its digest,
|
|
||||||
// silently dropping it from the index.
|
|
||||||
debug_assert!(
|
|
||||||
path.to_str().is_some(),
|
|
||||||
"an unrepresentable path reached prepare(): {:?}",
|
|
||||||
path
|
|
||||||
);
|
|
||||||
let db_path = path_to_db_string(&path);
|
let db_path = path_to_db_string(&path);
|
||||||
let digest = path_digest(&db_path);
|
let digest = path_digest(&db_path);
|
||||||
let aliased = matches!(known, Known::Exact(_));
|
let aliased = matches!(known, Known::Exact(_));
|
||||||
|
|
||||||
|
// A name that is not valid UTF-8 cannot be stored in `files.path`. Emitted
|
||||||
|
// as `Skip` because the caller reads a missing path as "deleted".
|
||||||
|
if warn_if_unrepresentable(&path) {
|
||||||
|
return WalkedFile::skipped(db_path, digest, aliased);
|
||||||
|
}
|
||||||
|
|
||||||
let Ok(meta) = crate::platform::metadata_or_stat(&path, cached) else {
|
let Ok(meta) = crate::platform::metadata_or_stat(&path, cached) else {
|
||||||
// Seen but unreadable: a transient stat failure must not read as
|
// Seen but unreadable: a transient stat failure must not read as
|
||||||
// "deleted".
|
// "deleted".
|
||||||
|
|
@ -501,15 +391,11 @@ fn prepare(file: PendingFile, known: Known<'_>, ctx: &Ctx) -> WalkedFile {
|
||||||
|
|
||||||
let action = match known {
|
let action = match known {
|
||||||
Known::InDir(rows) => {
|
Known::InDir(rows) => {
|
||||||
// `to_str`, not `to_string_lossy`: this name is looked up in the
|
|
||||||
// directory's stored rows, and the lossy spelling of one file is a
|
|
||||||
// valid name for another. The screen in `read_directory` is what
|
|
||||||
// makes it always `Some`.
|
|
||||||
let name = path
|
let name = path
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
classify_for_indexing(name, mtime, rows)
|
classify_for_indexing(&name, mtime, rows)
|
||||||
}
|
}
|
||||||
Known::Exact(stored) => classify_by_mtime(stored, mtime),
|
Known::Exact(stored) => classify_by_mtime(stored, mtime),
|
||||||
};
|
};
|
||||||
|
|
@ -608,7 +494,7 @@ fn prefetcher(shared: &Shared, db_path: &str) {
|
||||||
while let Some(work) = shared.take_prefetch() {
|
while let Some(work) = shared.take_prefetch() {
|
||||||
match work {
|
match work {
|
||||||
PrefetchWork::Dir(dir) => {
|
PrefetchWork::Dir(dir) => {
|
||||||
match crate::db::repo::dir_rows(&conn, &dir_to_db_parent(&dir)) {
|
match crate::db::repo::dir_rows(&conn, &path_to_db_string(&dir)) {
|
||||||
Ok(rows) => shared.finish_prefetch(Job::Dir(dir, Arc::new(rows))),
|
Ok(rows) => shared.finish_prefetch(Job::Dir(dir, Arc::new(rows))),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::log_warn!("{}", e);
|
crate::log_warn!("{}", e);
|
||||||
|
|
@ -665,7 +551,7 @@ impl ParallelWalk {
|
||||||
crate::lock_ok(&self.shared.queue)
|
crate::lock_ok(&self.shared.queue)
|
||||||
.seen_dirs
|
.seen_dirs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|d| dir_to_db_parent(d))
|
.map(|d| path_to_db_string(d))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -826,22 +712,6 @@ pub fn walk_indexable_files(
|
||||||
//
|
//
|
||||||
// Roots themselves are never filtered — the user chose them.
|
// Roots themselves are never filtered — the user chose them.
|
||||||
match fs::canonicalize(root) {
|
match fs::canonicalize(root) {
|
||||||
// A root string is UTF-8 by construction — it came from the config
|
|
||||||
// — but `canonicalize` resolves symlinks, so what it resolves *to*
|
|
||||||
// need not be: `~/docs` can be a link to a directory whose real
|
|
||||||
// name the index cannot spell. Stored lossily, the root would be
|
|
||||||
// walked under a parent string that names some other directory
|
|
||||||
// entirely. Treated exactly like a root that would not resolve at
|
|
||||||
// all, which is what it amounts to: yields nothing, and is recorded
|
|
||||||
// so stale cleanup does not read that as "everything was deleted".
|
|
||||||
Ok(dir) if dir.to_str().is_none() => {
|
|
||||||
crate::log_warn!(
|
|
||||||
"cannot index root {}: it resolves to {:?}, whose name is not valid UTF-8",
|
|
||||||
root,
|
|
||||||
dir
|
|
||||||
);
|
|
||||||
unresolvable.push(PathBuf::from(root));
|
|
||||||
}
|
|
||||||
Ok(dir) => {
|
Ok(dir) => {
|
||||||
let dir = PathBuf::from(path_to_db_string(&dir));
|
let dir = PathBuf::from(path_to_db_string(&dir));
|
||||||
if queue.seen_dirs.insert(dir.clone()) {
|
if queue.seen_dirs.insert(dir.clone()) {
|
||||||
|
|
@ -868,7 +738,6 @@ pub fn walk_indexable_files(
|
||||||
follow_symlinks,
|
follow_symlinks,
|
||||||
include_hidden,
|
include_hidden,
|
||||||
ignore,
|
ignore,
|
||||||
index_files: crate::file_handling::index_file_set(Path::new(db_path)),
|
|
||||||
pruned: PruneCounts::default(),
|
pruned: PruneCounts::default(),
|
||||||
config,
|
config,
|
||||||
registry,
|
registry,
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,16 @@ fn db_with(tag: &str, rows: &[(String, u64)]) -> PathBuf {
|
||||||
let p = crate::testutil::scratch_dir(tag).join("index.sqlite");
|
let p = crate::testutil::scratch_dir(tag).join("index.sqlite");
|
||||||
let conn = crate::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
let conn = crate::db::open_or_recreate(p.to_str().unwrap(), "trigram").unwrap();
|
||||||
for (path, mtime) in rows {
|
for (path, mtime) in rows {
|
||||||
// Split the same way the indexer does, so the seeded parent carries
|
let as_path = Path::new(path);
|
||||||
// its trailing separator and the prefetcher's `parent = ?` finds it.
|
|
||||||
let (parent, name) = crate::file_handling::split_db_path(path).expect("a file's path");
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO files (name, parent, size, mtime, type, content_state)
|
"INSERT INTO files (name, path, parent, size, mtime, type, content_state)
|
||||||
VALUES (?1, ?2, 0, ?3, 0, 3)",
|
VALUES (?1, ?2, ?3, 0, ?4, 0, 3)",
|
||||||
rusqlite::params![name, parent, *mtime as i64],
|
rusqlite::params![
|
||||||
|
as_path.file_name().unwrap().to_string_lossy(),
|
||||||
|
path,
|
||||||
|
as_path.parent().unwrap().to_string_lossy(),
|
||||||
|
*mtime as i64,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
@ -105,237 +108,35 @@ fn walks_a_nested_tree_exactly_once() {
|
||||||
fs::remove_dir_all(&root).ok();
|
fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Put `name` on disk under `dir`, or report that this filesystem refused it.
|
/// A name that is not valid UTF-8 must be skipped, not deleted. Unix only:
|
||||||
///
|
/// on Windows `OsString` comes from UTF-16 and the case cannot be built.
|
||||||
/// FAT, exFAT and some network redirectors reject the names
|
#[cfg(unix)]
|
||||||
/// [`crate::testutil::unrepresentable_name`] builds. A test that cannot create
|
|
||||||
/// one has nothing to assert and says so, rather than failing on the
|
|
||||||
/// filesystem's behalf.
|
|
||||||
fn try_touch(path: &Path) -> bool {
|
|
||||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
fs::write(path, b"x").is_ok() && path.symlink_metadata().is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_mkdir(path: &Path) -> bool {
|
|
||||||
fs::create_dir_all(path).is_ok() && path.symlink_metadata().is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A name the index cannot spell is dropped at the directory entry, before any
|
|
||||||
/// string is built from it — so it is neither prepared nor reported.
|
|
||||||
///
|
|
||||||
/// Both platforms, and the Windows half is not the exotic one: `OsString`
|
|
||||||
/// there is WTF-8 over UTF-16, and `OsString::from_wide(&[0xD800])` builds an
|
|
||||||
/// unpaired surrogate that NTFS stores happily. (An older comment here claimed
|
|
||||||
/// the case could not be built on Windows. It can, which is exactly why WSL
|
|
||||||
/// and Samba trees hit it.)
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_non_utf8_name_is_dropped_at_the_entry() {
|
fn a_non_utf8_name_is_skipped_and_never_prepared() {
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
use std::os::unix::ffi::OsStrExt;
|
||||||
|
|
||||||
let root = tmp_tree("nonutf8");
|
let root = tmp_tree("nonutf8");
|
||||||
touch(&root.join("plain.txt"));
|
touch(&root.join("plain.txt"));
|
||||||
let bad = root.join(crate::testutil::unrepresentable_name("DRH257", "~X.MP4"));
|
// 0xFF only survives `to_string_lossy` as U+FFFD.
|
||||||
if !try_touch(&bad) {
|
let bad = root.join(OsStr::from_bytes(b"DRH257\xff~X.MP4"));
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
touch(&bad);
|
||||||
return;
|
assert!(bad.symlink_metadata().is_ok(), "the file really is on disk");
|
||||||
}
|
|
||||||
|
|
||||||
let files = walk(&root, &empty_db("nonutf8"));
|
let files = walk(&root, &empty_db("nonutf8"));
|
||||||
|
|
||||||
// Not yielded at all — a `WalkedFile` for it would carry the lossy path as
|
// Both are yielded, so neither reads as deleted...
|
||||||
// its key and its duplicate-visit digest.
|
assert_eq!(files.len(), 2, "the bad name is still reported as seen");
|
||||||
assert_eq!(names(&files), vec!["plain.txt"]);
|
// ...but only the representable one is prepared for insertion.
|
||||||
assert!(
|
|
||||||
files.iter().all(|f| !f.path.contains('\u{FFFD}')),
|
|
||||||
"no lossy spelling may reach the writer: {:?}",
|
|
||||||
files.iter().map(|f| &f.path).collect::<Vec<_>>()
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The safety argument for dropping the entry, pinned: a `continue` in
|
|
||||||
/// `read_directory` leaves the name out of `present`, and everything left out
|
|
||||||
/// of `present` is deleted.
|
|
||||||
///
|
|
||||||
/// It is safe because the dropped entry has no row of its own — an
|
|
||||||
/// unrepresentable name cannot be stored — and because the row that *is*
|
|
||||||
/// named its lossy spelling belongs to a different file, which the same
|
|
||||||
/// listing yields separately and which marks itself present. This test is what
|
|
||||||
/// says that second half still happens.
|
|
||||||
#[test]
|
|
||||||
fn dropping_a_bad_entry_deletes_nothing() {
|
|
||||||
let root = tmp_tree("nonutf8-stale");
|
|
||||||
let twin = crate::testutil::lossy_twin("DRH257", "~X.MP4");
|
|
||||||
touch(&root.join("plain.txt"));
|
|
||||||
touch(&root.join(&twin));
|
|
||||||
let bad = root.join(crate::testutil::unrepresentable_name("DRH257", "~X.MP4"));
|
|
||||||
if !try_touch(&bad) {
|
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seed both real files as already indexed, so anything the walk reports
|
|
||||||
// stale is a row it wants deleted.
|
|
||||||
let db = db_with(
|
|
||||||
"nonutf8-stale",
|
|
||||||
&[
|
|
||||||
(path_to_db_string(&root.join("plain.txt")), 0),
|
|
||||||
(path_to_db_string(&root.join(&twin)), 0),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
let stale = stale_only(walk_indexable_files(
|
|
||||||
&[root.to_string_lossy().into_owned()],
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
IgnoreSet::compile(&[]).unwrap(),
|
|
||||||
db.to_str().unwrap(),
|
|
||||||
Config::default(),
|
|
||||||
Arc::new(Registry::default_set()),
|
|
||||||
Arc::new(AtomicBool::new(false)),
|
|
||||||
4,
|
|
||||||
));
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
stale.is_empty(),
|
|
||||||
"both files are on disk; nothing may be deleted: {:?}",
|
|
||||||
stale
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A root spelled in UTF-8 can still *resolve* onto a name that is not:
|
|
||||||
/// canonicalizing follows symlinks, so `~/docs` may be a link to a directory
|
|
||||||
/// the index cannot spell.
|
|
||||||
///
|
|
||||||
/// Stored lossily, that root's parent string names some other directory — so
|
|
||||||
/// the walk would attribute a completely unrelated tree to it, or read nothing
|
|
||||||
/// and report the root's real rows as deleted. It has to be recorded as
|
|
||||||
/// unreadable instead, which is what keeps stale cleanup off it.
|
|
||||||
#[cfg(unix)]
|
|
||||||
#[test]
|
|
||||||
fn a_root_that_resolves_onto_an_unrepresentable_name_is_not_walked() {
|
|
||||||
let base = tmp_tree("nonutf8-root");
|
|
||||||
let real = base.join(crate::testutil::unrepresentable_name("target", ""));
|
|
||||||
if !try_mkdir(&real) {
|
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
touch(&real.join("inside.txt"));
|
|
||||||
|
|
||||||
// The root the user configures is an ordinary, spellable string.
|
|
||||||
let link = base.join("docs");
|
|
||||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
|
||||||
let root = link.to_str().expect("the configured root is spellable");
|
|
||||||
|
|
||||||
let mut walk = walk_indexable_files(
|
|
||||||
&[root.to_string()],
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
IgnoreSet::compile(&[]).unwrap(),
|
|
||||||
empty_db("nonutf8-root").to_str().unwrap(),
|
|
||||||
Config::default(),
|
|
||||||
Arc::new(Registry::default_set()),
|
|
||||||
Arc::new(AtomicBool::new(false)),
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
let events: Vec<WalkEvent> = walk.by_ref().collect();
|
|
||||||
assert!(
|
|
||||||
events.is_empty(),
|
|
||||||
"nothing under an unspellable root may be walked: {:?}",
|
|
||||||
events
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!walk.unreadable().is_empty(),
|
|
||||||
"the root must read as unreadable, or its rows fall to stale cleanup"
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&base).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The collision that made this worth fixing: a file whose *lossy* spelling is
|
|
||||||
/// another file's real name must not stand in for it.
|
|
||||||
///
|
|
||||||
/// Before the screen moved to the directory entry, the bad name was still
|
|
||||||
/// turned into a `WalkedFile` carrying the lossy path — and the pipeline hashes
|
|
||||||
/// that path into `seen_paths` before it looks for a record, so whichever of
|
|
||||||
/// the two the walk happened to reach second was dropped from the index.
|
|
||||||
#[test]
|
|
||||||
fn a_bad_name_cannot_stand_in_for_its_lossy_twin() {
|
|
||||||
let root = tmp_tree("nonutf8-twin");
|
|
||||||
let twin = crate::testutil::lossy_twin("x", ".txt");
|
|
||||||
touch(&root.join(&twin));
|
|
||||||
let bad = root.join(crate::testutil::unrepresentable_name("x", ".txt"));
|
|
||||||
if !try_touch(&bad) {
|
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let files = walk(&root, &empty_db("nonutf8-twin"));
|
|
||||||
|
|
||||||
let prepared: Vec<&WalkedFile> = files.iter().filter(|f| f.record.is_some()).collect();
|
let prepared: Vec<&WalkedFile> = files.iter().filter(|f| f.record.is_some()).collect();
|
||||||
assert_eq!(
|
assert_eq!(prepared.len(), 1);
|
||||||
prepared.len(),
|
assert!(prepared[0].path.ends_with("plain.txt"));
|
||||||
1,
|
|
||||||
"exactly the representable file is prepared"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
prepared[0].path,
|
|
||||||
path_to_db_string(&root.join(&twin)),
|
|
||||||
"and it is the real one, under its own name"
|
|
||||||
);
|
|
||||||
|
|
||||||
fs::remove_dir_all(&root).ok();
|
let skipped = files.iter().find(|f| f.record.is_none()).unwrap();
|
||||||
}
|
assert!(matches!(skipped.action, FileIndexAction::Skip));
|
||||||
|
|
||||||
/// A directory the index cannot spell is pruned at its root: every path
|
|
||||||
/// beneath it would carry the bad component, so none of them is indexable
|
|
||||||
/// either way.
|
|
||||||
///
|
|
||||||
/// The reason this is the *directory* case and not just N file cases: the
|
|
||||||
/// walk's row prefetcher keys on the directory's lossy parent string, so a bad
|
|
||||||
/// directory sitting beside a real one with the colliding name was handed the
|
|
||||||
/// real one's rows — and then reported every one of them stale.
|
|
||||||
#[test]
|
|
||||||
fn a_bad_directory_is_pruned_with_its_whole_subtree() {
|
|
||||||
let root = tmp_tree("nonutf8-dir");
|
|
||||||
let twin = crate::testutil::lossy_twin("dir", "");
|
|
||||||
touch(&root.join(&twin).join("a.txt"));
|
|
||||||
let bad = root.join(crate::testutil::unrepresentable_name("dir", ""));
|
|
||||||
if !try_mkdir(&bad) {
|
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for n in 0..5 {
|
|
||||||
touch(&bad.join(format!("b{}.txt", n)));
|
|
||||||
}
|
|
||||||
touch(&bad.join("deep/c.txt"));
|
|
||||||
|
|
||||||
let db = db_with(
|
|
||||||
"nonutf8-dir",
|
|
||||||
&[(path_to_db_string(&root.join(&twin).join("a.txt")), 0)],
|
|
||||||
);
|
|
||||||
let stale = stale_only(walk_indexable_files(
|
|
||||||
&[root.to_string_lossy().into_owned()],
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
IgnoreSet::compile(&[]).unwrap(),
|
|
||||||
db.to_str().unwrap(),
|
|
||||||
Config::default(),
|
|
||||||
Arc::new(Registry::default_set()),
|
|
||||||
Arc::new(AtomicBool::new(false)),
|
|
||||||
4,
|
|
||||||
));
|
|
||||||
assert!(
|
assert!(
|
||||||
stale.is_empty(),
|
skipped.path.contains('\u{FFFD}'),
|
||||||
"the real directory's row must survive its bad-named sibling: {:?}",
|
"stored spelling is the lossy one"
|
||||||
stale
|
|
||||||
);
|
|
||||||
|
|
||||||
let files = walk(&root, &db);
|
|
||||||
assert_eq!(
|
|
||||||
names(&files),
|
|
||||||
vec!["a.txt"],
|
|
||||||
"nothing under the bad directory is walked"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fs::remove_dir_all(&root).ok();
|
fs::remove_dir_all(&root).ok();
|
||||||
|
|
@ -1003,97 +804,3 @@ fn local_temp_dir_is_not_detected_as_network() {
|
||||||
);
|
);
|
||||||
fs::remove_dir_all(&root).ok();
|
fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// **The index must never be walked into.**
|
|
||||||
///
|
|
||||||
/// Hashing a file means opening it, and on Unix closing a descriptor on an
|
|
||||||
/// inode cancels every advisory lock the whole process holds on it. Doing that
|
|
||||||
/// to `index.sqlite-shm` destroys the DMS lock SQLite took, after which an
|
|
||||||
/// attaching connection truncates the wal-index under our live mapping and the
|
|
||||||
/// next commit dies with SIGBUS — which is the crash this pruning exists to
|
|
||||||
/// prevent. The same close cancels the database's own locks, SQLite's
|
|
||||||
/// documented corruption hazard.
|
|
||||||
///
|
|
||||||
/// `include_hidden` is on because that is what exposes the default layout:
|
|
||||||
/// the index lives under `~/.local/share`, which a default walk skips for
|
|
||||||
/// being dot-named — so the hazard is real but latent until a user turns
|
|
||||||
/// hidden files on.
|
|
||||||
#[test]
|
|
||||||
fn the_index_and_its_sidecars_are_never_walked() {
|
|
||||||
let root = tmp_tree("walk-self-index");
|
|
||||||
touch(&root.join("ordinary.txt"));
|
|
||||||
|
|
||||||
// The index inside the tree being walked, as it is by default: the
|
|
||||||
// default root is the home directory and the default database sits
|
|
||||||
// beneath it.
|
|
||||||
let db = root.join("data").join("index.sqlite");
|
|
||||||
let conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
// A write, so the WAL and SHM exist to be walked over.
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO files (name, parent, size, mtime, type, content_state)
|
|
||||||
VALUES ('a', '/', 0, 0, 0, 3)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let found = names(&walk_with(&root, &db, false, true));
|
|
||||||
assert!(
|
|
||||||
found.contains(&"ordinary.txt".to_string()),
|
|
||||||
"the walk should still report ordinary files: {:?}",
|
|
||||||
found
|
|
||||||
);
|
|
||||||
for name in ["index.sqlite", "index.sqlite-wal", "index.sqlite-shm"] {
|
|
||||||
assert!(
|
|
||||||
!found.contains(&name.to_string()),
|
|
||||||
"{} was walked; found {:?}",
|
|
||||||
name,
|
|
||||||
found
|
|
||||||
);
|
|
||||||
}
|
|
||||||
drop(conn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pruning it is not enough — a row written before this existed has to go, or
|
|
||||||
/// the live watcher and duplicate verification keep opening the file from a
|
|
||||||
/// result list forever.
|
|
||||||
///
|
|
||||||
/// This is why the walk `continue`s past the index rather than emitting a
|
|
||||||
/// `WalkedFile::skipped`: a skip keeps the row, and only leaving the name out
|
|
||||||
/// of the directory's `present` set hands it to the stale sweep.
|
|
||||||
#[test]
|
|
||||||
fn a_stored_row_for_the_index_falls_to_the_stale_sweep() {
|
|
||||||
let root = crate::testutil::scratch_dir_canonical("walk-self-index-stale");
|
|
||||||
// The database the walk uses has to be the one inside the walked tree,
|
|
||||||
// or there is nothing self-referential to sweep.
|
|
||||||
let db = root.join("data").join("index.sqlite");
|
|
||||||
let conn = crate::db::open_or_recreate(db.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
|
|
||||||
// The row an older build would have written for the database itself,
|
|
||||||
// inserted exactly the way `db_with` inserts one.
|
|
||||||
let stored = path_to_db_string(&db);
|
|
||||||
let (parent, name) = crate::file_handling::split_db_path(&stored).expect("a file's path");
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO files (name, parent, size, mtime, type, content_state)
|
|
||||||
VALUES (?1, ?2, 0, 0, 0, 3)",
|
|
||||||
rusqlite::params![name, parent],
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
drop(conn);
|
|
||||||
|
|
||||||
let stale = stale_only(walk_indexable_files(
|
|
||||||
&[root.to_string_lossy().into_owned()],
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
IgnoreSet::compile(&[]).unwrap(),
|
|
||||||
db.to_str().unwrap(),
|
|
||||||
Config::default(),
|
|
||||||
Arc::new(Registry::default_set()),
|
|
||||||
Arc::new(AtomicBool::new(false)),
|
|
||||||
4,
|
|
||||||
));
|
|
||||||
assert!(
|
|
||||||
stale.contains(&stored),
|
|
||||||
"the index's own row should be swept; got {:?}",
|
|
||||||
stale
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
//! root looks live while going silently stale. A single directory the kernel
|
//! root looks live while going silently stale. A single directory the kernel
|
||||||
//! refuses is logged and skipped instead (see [`add_watch`]).
|
//! refuses is logged and skipped instead (see [`add_watch`]).
|
||||||
|
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
use std::sync::{mpsc, Arc, Mutex};
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
|
@ -97,11 +97,6 @@ pub enum WatchError {
|
||||||
KernelLimit {
|
KernelLimit {
|
||||||
registered: usize,
|
registered: usize,
|
||||||
},
|
},
|
||||||
/// The kernel's event queue overflowed and events were dropped. Unlike
|
|
||||||
/// the two above this says nothing about the watcher's *capacity* — it
|
|
||||||
/// keeps working — only that the index is now out of step with the disk
|
|
||||||
/// by an unknown amount, so a full run is owed.
|
|
||||||
Overflowed,
|
|
||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,11 +122,6 @@ impl std::fmt::Display for WatchError {
|
||||||
""
|
""
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
WatchError::Overflowed => write!(
|
|
||||||
f,
|
|
||||||
"the system event queue overflowed and changes were missed; \
|
|
||||||
reindexing to catch up"
|
|
||||||
),
|
|
||||||
WatchError::Other(msg) => write!(f, "{}", msg),
|
WatchError::Other(msg) => write!(f, "{}", msg),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -166,9 +156,8 @@ pub struct WatcherConfig {
|
||||||
/// How often the tick loop inspects the throttle map. Short ticks mean
|
/// How often the tick loop inspects the throttle map. Short ticks mean
|
||||||
/// low latency for first-in-a-burst; long ticks lower CPU at idle.
|
/// low latency for first-in-a-burst; long ticks lower CPU at idle.
|
||||||
pub tick_interval: Duration,
|
pub tick_interval: Duration,
|
||||||
/// Maximum directories *selected* per tick. A ceiling on the work one
|
/// Maximum directories processed per tick. Caps the time spent in a
|
||||||
/// pass takes on; [`FLUSH_BUDGET`] is what bounds how long it may spend
|
/// single flush pass so long backlogs don't monopolize the thread.
|
||||||
/// on them, and is the real limit.
|
|
||||||
pub max_dirs_per_tick: usize,
|
pub max_dirs_per_tick: usize,
|
||||||
/// When to garbage-collect stale throttle entries (idle > window * N).
|
/// When to garbage-collect stale throttle entries (idle > window * N).
|
||||||
pub prune_max_age_multiplier: u32,
|
pub prune_max_age_multiplier: u32,
|
||||||
|
|
@ -183,7 +172,7 @@ impl Default for WatcherConfig {
|
||||||
pending_max_defer: Duration::from_secs(30),
|
pending_max_defer: Duration::from_secs(30),
|
||||||
throttle_window: Duration::from_secs(30),
|
throttle_window: Duration::from_secs(30),
|
||||||
tick_interval: Duration::from_millis(500),
|
tick_interval: Duration::from_millis(500),
|
||||||
max_dirs_per_tick: 512,
|
max_dirs_per_tick: 64,
|
||||||
prune_max_age_multiplier: 10,
|
prune_max_age_multiplier: 10,
|
||||||
max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS,
|
max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS,
|
||||||
}
|
}
|
||||||
|
|
@ -198,23 +187,10 @@ impl Default for WatcherConfig {
|
||||||
/// never blocks behind a large subtree registration.
|
/// never blocks behind a large subtree registration.
|
||||||
struct WatchRegistry {
|
struct WatchRegistry {
|
||||||
raw: RecommendedWatcher,
|
raw: RecommendedWatcher,
|
||||||
/// Ordered, not hashed, so [`WatchRegistry::remove_tree`] can take the
|
dirs: HashSet<PathBuf>,
|
||||||
/// subtree as a range instead of scanning every watched directory. The
|
|
||||||
/// set reaches `max_watched_dirs` (128k by default) and `rm -rf` deletes
|
|
||||||
/// bottom-up, so every directory in a deleted tree hits that path.
|
|
||||||
dirs: BTreeSet<PathBuf>,
|
|
||||||
cap: usize,
|
cap: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How long one flush pass may spend handing events to the sink.
|
|
||||||
///
|
|
||||||
/// Paired with `max_dirs_per_tick`: the count decides how many directories a
|
|
||||||
/// pass takes on, this decides when it stops regardless. A backlog then
|
|
||||||
/// drains at whatever the machine can actually do rather than at a fixed
|
|
||||||
/// directories-per-second, while one enormous directory still cannot hold the
|
|
||||||
/// tick loop.
|
|
||||||
const FLUSH_BUDGET: Duration = Duration::from_millis(50);
|
|
||||||
|
|
||||||
/// The mode every `watch()` call uses on this platform. See
|
/// The mode every `watch()` call uses on this platform. See
|
||||||
/// [`crate::platform::WATCH_ROOTS_RECURSIVELY`] for why it differs.
|
/// [`crate::platform::WATCH_ROOTS_RECURSIVELY`] for why it differs.
|
||||||
const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY {
|
const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY {
|
||||||
|
|
@ -267,15 +243,10 @@ impl WatchRegistry {
|
||||||
if !self.dirs.contains(dir) {
|
if !self.dirs.contains(dir) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
// A range from `dir`, stopping at the first entry that is no longer
|
|
||||||
// beneath it: descendants sort immediately after their ancestor, so
|
|
||||||
// this visits the subtree and one entry more, rather than the whole
|
|
||||||
// set. `starts_with` is still the test — it compares whole components,
|
|
||||||
// where a raw string prefix would take `/a/bc` for a child of `/a/b`.
|
|
||||||
let doomed: Vec<PathBuf> = self
|
let doomed: Vec<PathBuf> = self
|
||||||
.dirs
|
.dirs
|
||||||
.range(dir.to_path_buf()..)
|
.iter()
|
||||||
.take_while(|d| d.starts_with(dir))
|
.filter(|d| d.starts_with(dir))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
for d in &doomed {
|
for d in &doomed {
|
||||||
|
|
@ -340,21 +311,6 @@ impl Watcher {
|
||||||
let raw = RecommendedWatcher::new(
|
let raw = RecommendedWatcher::new(
|
||||||
move |res: notify::Result<NotifyEvent>| match res {
|
move |res: notify::Result<NotifyEvent>| match res {
|
||||||
Ok(ev) => {
|
Ok(ev) => {
|
||||||
// An overflow of the kernel's own event queue arrives here
|
|
||||||
// — on the *Ok* arm, as `EventKind::Other` with the rescan
|
|
||||||
// flag and no paths at all — so the error arm below never
|
|
||||||
// sees it and the per-path loop downstream iterates zero
|
|
||||||
// times. Left alone it is silent data loss: an arbitrary
|
|
||||||
// set of creates, modifies and removes never reaches the
|
|
||||||
// index while the watcher goes on reporting itself
|
|
||||||
// healthy. It is reported rather than repaired here
|
|
||||||
// because the events are simply gone; only a full run can
|
|
||||||
// find out what happened.
|
|
||||||
if ev.need_rescan() {
|
|
||||||
let mut slot = crate::lock_ok(°raded_cb);
|
|
||||||
slot.get_or_insert(WatchError::Overflowed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A closed receiver just means the watcher was stopped; ignore.
|
// A closed receiver just means the watcher was stopped; ignore.
|
||||||
let _ = tx.send(ev);
|
let _ = tx.send(ev);
|
||||||
}
|
}
|
||||||
|
|
@ -374,7 +330,7 @@ impl Watcher {
|
||||||
|
|
||||||
let registry = Arc::new(Mutex::new(WatchRegistry {
|
let registry = Arc::new(Mutex::new(WatchRegistry {
|
||||||
raw,
|
raw,
|
||||||
dirs: BTreeSet::new(),
|
dirs: HashSet::new(),
|
||||||
cap: config.max_watched_dirs,
|
cap: config.max_watched_dirs,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -460,19 +416,6 @@ impl Watcher {
|
||||||
crate::lock_ok(&self.degraded).is_some()
|
crate::lock_ok(&self.degraded).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Forget the recorded reason, so a later one can take its place.
|
|
||||||
///
|
|
||||||
/// For [`WatchError::Overflowed`] only, and the distinction is the whole
|
|
||||||
/// point of the method: the other two reasons are *standing* — the watch
|
|
||||||
/// budget does not come back — while an overflow is a one-shot "you missed
|
|
||||||
/// some" from a watcher that is still delivering. Left in place it would
|
|
||||||
/// re-trigger on every coordinator tick and, worse, mask a real
|
|
||||||
/// [`WatchError::KernelLimit`] arriving afterwards, because the callback
|
|
||||||
/// records with `get_or_insert`.
|
|
||||||
pub fn clear_degraded(&self) {
|
|
||||||
*crate::lock_ok(&self.degraded) = None;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signal the background thread to stop and wait for it to join. Safe to
|
/// Signal the background thread to stop and wait for it to join. Safe to
|
||||||
/// call multiple times.
|
/// call multiple times.
|
||||||
pub fn stop(&mut self) {
|
pub fn stop(&mut self) {
|
||||||
|
|
@ -622,19 +565,8 @@ fn watch_if_new_dir(ctx: &LoopCtx, path: &Path) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Files are reported through their parent's watch; only directories
|
// Files are reported through their parent's watch; only directories
|
||||||
// need one of their own. `symlink_metadata` rather than `is_dir` so a
|
// need one of their own.
|
||||||
// symlink to a directory is judged as the link it is: when following is
|
if !path.is_dir() {
|
||||||
// off the walk will not descend it, and watching it would spend
|
|
||||||
// descriptors reporting events for a subtree that is never indexed. When
|
|
||||||
// following is on it is a directory as far as everything else is
|
|
||||||
// concerned, so fall back to the followed answer.
|
|
||||||
let is_dir = match std::fs::symlink_metadata(path) {
|
|
||||||
Ok(md) if md.file_type().is_symlink() => ctx.filters.follow_symlinks && path.is_dir(),
|
|
||||||
Ok(md) => md.is_dir(),
|
|
||||||
// Raced with a delete, or unreadable: nothing to register.
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
if !is_dir {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !is_event_interesting(ctx, path) {
|
if !is_event_interesting(ctx, path) {
|
||||||
|
|
@ -662,16 +594,6 @@ fn unwatch_tree(ctx: &LoopCtx, path: &Path) {
|
||||||
/// on which subtrees exist. Under a recursive root watch it is the *only*
|
/// on which subtrees exist. Under a recursive root watch it is the *only*
|
||||||
/// thing keeping `node_modules` churn out of the throttle map.
|
/// thing keeping `node_modules` churn out of the throttle map.
|
||||||
fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool {
|
fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool {
|
||||||
// A path the index cannot spell, screened here because this is the one
|
|
||||||
// gate every `FsEvent` passes through. Such a file is never indexed, so
|
|
||||||
// there is no row for a Create to update and none for a Remove to delete —
|
|
||||||
// but the incremental side keys on `path_to_db_string`, which is lossy, so
|
|
||||||
// letting the event through means acting on whichever *different* file
|
|
||||||
// happens to own the lossy spelling. The event carries no information and
|
|
||||||
// every use of it is a mistake.
|
|
||||||
if path.to_str().is_none() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ctx.filters.ignore.matches_path(path) {
|
if ctx.filters.ignore.matches_path(path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -774,18 +696,7 @@ fn flush_ready(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The count above bounds how many directories are *selected*; this bounds
|
|
||||||
// how long draining them may take, which is the thing that actually
|
|
||||||
// matters. Directory queues differ by orders of magnitude, so a fixed
|
|
||||||
// count is either too small after a large delete — at 64 per 500 ms tick
|
|
||||||
// the drain rate is 128 directories a second regardless of backlog, and a
|
|
||||||
// 20k-directory unpack takes minutes to reach the index — or too large for
|
|
||||||
// one deep directory.
|
|
||||||
let deadline = now + FLUSH_BUDGET;
|
|
||||||
for dir in ready {
|
for dir in ready {
|
||||||
if Instant::now() >= deadline {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if let Some(entry) = throttle.get_mut(&dir) {
|
if let Some(entry) = throttle.get_mut(&dir) {
|
||||||
let drained: Vec<(PathBuf, QueuedOp)> = entry.queue.drain().collect();
|
let drained: Vec<(PathBuf, QueuedOp)> = entry.queue.drain().collect();
|
||||||
entry.immediate = false;
|
entry.immediate = false;
|
||||||
|
|
|
||||||
|
|
@ -189,75 +189,6 @@ fn e2e_create_modify_remove_surfaces() {
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file the index cannot spell must produce no event at all.
|
|
||||||
///
|
|
||||||
/// It is never indexed, so no row exists for a `Create` to update or a
|
|
||||||
/// `Remove` to delete — but the incremental side keys on `path_to_db_string`,
|
|
||||||
/// which is lossy, so an event that got through would be applied to whichever
|
|
||||||
/// *different* file owns the lossy spelling. Screened in
|
|
||||||
/// [`is_event_interesting`], the one gate every `FsEvent` passes through.
|
|
||||||
///
|
|
||||||
/// A real file is created alongside, so a run where the watcher simply saw
|
|
||||||
/// nothing cannot pass by accident.
|
|
||||||
#[test]
|
|
||||||
fn events_for_an_unrepresentable_name_never_surface() {
|
|
||||||
let dir = tmp_dir("e2e-unrepresentable");
|
|
||||||
let bad = dir.join(crate::testutil::unrepresentable_name("report", ".txt"));
|
|
||||||
if std::fs::write(&bad, "hi").is_err() {
|
|
||||||
eprintln!("skipped: this filesystem will not store an unrepresentable name");
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::fs::remove_file(&bad).unwrap();
|
|
||||||
|
|
||||||
let (sink, got) = sink_to_vec();
|
|
||||||
let mut w = Watcher::start(
|
|
||||||
std::iter::once(&dir),
|
|
||||||
default_filters(),
|
|
||||||
fast_config(),
|
|
||||||
sink,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
std::fs::write(&bad, "hi").unwrap();
|
|
||||||
std::thread::sleep(Duration::from_millis(150));
|
|
||||||
std::fs::remove_file(&bad).unwrap();
|
|
||||||
// The control: whatever the backend does for the bad name, it certainly
|
|
||||||
// reports this one, so an empty event list means the watcher was working.
|
|
||||||
let good = dir.join("ordinary.txt");
|
|
||||||
std::fs::write(&good, "hi").unwrap();
|
|
||||||
std::thread::sleep(Duration::from_millis(250));
|
|
||||||
|
|
||||||
w.stop();
|
|
||||||
|
|
||||||
let events = got.lock().unwrap().clone();
|
|
||||||
assert!(
|
|
||||||
events.iter().any(|e| matches!(
|
|
||||||
e,
|
|
||||||
FsEvent::Create(p) | FsEvent::Modify(p) if p == &good
|
|
||||||
)),
|
|
||||||
"the control file produced no event, so this test proves nothing: {:?}",
|
|
||||||
events
|
|
||||||
);
|
|
||||||
let leaked: Vec<&FsEvent> = events
|
|
||||||
.iter()
|
|
||||||
.filter(|e| {
|
|
||||||
let paths: Vec<&PathBuf> = match e {
|
|
||||||
FsEvent::Create(p) | FsEvent::Modify(p) | FsEvent::Remove(p) => vec![p],
|
|
||||||
FsEvent::Rename { from, to } => vec![from, to],
|
|
||||||
};
|
|
||||||
paths.iter().any(|p| p.to_str().is_none())
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
leaked.is_empty(),
|
|
||||||
"an unrepresentable path reached the sink: {:?}",
|
|
||||||
leaked
|
|
||||||
);
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Descriptors are not spent on directories the indexer would discard.
|
/// Descriptors are not spent on directories the indexer would discard.
|
||||||
#[test]
|
#[test]
|
||||||
fn ignored_and_hidden_dirs_are_not_registered() {
|
fn ignored_and_hidden_dirs_are_not_registered() {
|
||||||
|
|
@ -585,40 +516,3 @@ fn a_created_ignored_directory_is_not_watched() {
|
||||||
w.stop();
|
w.stop();
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registration must not follow a symlinked directory when the indexer will
|
|
||||||
/// not: every descriptor spent there reports events for a subtree that gets
|
|
||||||
/// discarded on arrival, and on Linux the watch budget is a shared kernel
|
|
||||||
/// resource.
|
|
||||||
#[test]
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn a_symlinked_directory_is_not_registered_when_following_is_off() {
|
|
||||||
let dir = tmp_dir("symlink-reg");
|
|
||||||
std::fs::create_dir_all(dir.join("real/nested")).unwrap();
|
|
||||||
let outside = tmp_dir("symlink-reg-target");
|
|
||||||
std::fs::create_dir_all(outside.join("deep")).unwrap();
|
|
||||||
std::os::unix::fs::symlink(&outside, dir.join("link")).unwrap();
|
|
||||||
|
|
||||||
let w = Watcher::start(
|
|
||||||
std::iter::once(&dir),
|
|
||||||
default_filters(),
|
|
||||||
fast_config(),
|
|
||||||
sink_to_vec().0,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// root + real + real/nested. The link and everything under it cost
|
|
||||||
// nothing.
|
|
||||||
assert_eq!(
|
|
||||||
w.watched_dirs(),
|
|
||||||
if crate::platform::WATCH_ROOTS_RECURSIVELY {
|
|
||||||
1
|
|
||||||
} else {
|
|
||||||
3
|
|
||||||
},
|
|
||||||
"the symlinked directory must not be registered"
|
|
||||||
);
|
|
||||||
drop(w);
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
|
||||||
std::fs::remove_dir_all(&outside).ok();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -31,18 +31,15 @@ impl Seeder {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Insert a file; `text: Some(..)` also content-indexes it.
|
/// Insert a file; `text: Some(..)` also content-indexes it.
|
||||||
///
|
|
||||||
/// `dir` is spelled without a trailing separator, as a caller naturally
|
|
||||||
/// would; the stored parent always carries one, because that is what makes
|
|
||||||
/// the path `parent || name` (see `file_handling::dir_to_db_parent`).
|
|
||||||
fn add(&mut self, name: &str, dir: &str, mtime: u64, text: Option<&str>) -> i64 {
|
fn add(&mut self, name: &str, dir: &str, mtime: u64, text: Option<&str>) -> i64 {
|
||||||
let parent = format!("{}/", dir);
|
let path = format!("{}/{}", dir, name);
|
||||||
let tx = self.conn.transaction().unwrap();
|
let tx = self.conn.transaction().unwrap();
|
||||||
let id = insert_file(
|
let id = insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name,
|
name,
|
||||||
parent: &parent,
|
path: &path,
|
||||||
|
parent: dir,
|
||||||
size: 42,
|
size: 42,
|
||||||
mtime,
|
mtime,
|
||||||
mime: Some("text/plain"),
|
mime: Some("text/plain"),
|
||||||
|
|
@ -564,117 +561,6 @@ fn limit_truncates_and_flags() {
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exactly `limit` results is a complete answer, not a cut one.
|
|
||||||
///
|
|
||||||
/// `remaining()` reaches zero at the pass boundary either way, so the outer
|
|
||||||
/// loop used to set `limited` there on its way out — surfacing as the CLI's
|
|
||||||
/// "(truncated at N results; raise with --limit)" and the GUI's equivalent
|
|
||||||
/// over a result set that had dropped nothing. `flush_pass` is the authority:
|
|
||||||
/// it sets the flag when it actually truncates.
|
|
||||||
///
|
|
||||||
/// Three under a limit of three is the case that isolates it: the first hit
|
|
||||||
/// flushes immediately, leaving room for two, and the pass ends with exactly
|
|
||||||
/// those two buffered — so nothing truncates, nothing breaks mid-scan, and
|
|
||||||
/// only the between-pass test could have raised the flag. See
|
|
||||||
/// [`filling_the_limit_mid_pass_still_reports_truncated`] for the boundary
|
|
||||||
/// this does not reach.
|
|
||||||
#[test]
|
|
||||||
fn exactly_the_limit_is_not_reported_as_truncated() {
|
|
||||||
let p = tmp_db("limit-exact");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
for i in 0..3 {
|
|
||||||
s.add(&format!("match-{:02}.txt", i), "/d", 1, None);
|
|
||||||
}
|
|
||||||
let conn = s.done();
|
|
||||||
|
|
||||||
let options = SearchOptions {
|
|
||||||
limit: 3,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let (hits, outcome) = run_collect(&conn, "match", &options);
|
|
||||||
assert_eq!(hits.len(), 3);
|
|
||||||
assert_eq!(outcome.total, 3);
|
|
||||||
assert!(
|
|
||||||
!outcome.limited,
|
|
||||||
"3 matches under a limit of 3 dropped nothing"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The boundary the fix above deliberately does *not* cover, pinned so it is
|
|
||||||
/// a known shape rather than a surprise.
|
|
||||||
///
|
|
||||||
/// A limit of one is filled by the first hit of the first pass — and the first
|
|
||||||
/// batch of a pass flushes the moment there is anything to send, so `total`
|
|
||||||
/// reaches the limit while the scan is still running. The scan then breaks
|
|
||||||
/// with rows unexamined, and `cut_short` reports that honestly as "there may
|
|
||||||
/// be more", which here there was not. Distinguishing the two costs a row
|
|
||||||
/// scanned past the limit in every pass that fills it — a document decompress,
|
|
||||||
/// fold and snippet on the full-text passes — which is not worth paying on
|
|
||||||
/// every keystroke to correct a message.
|
|
||||||
#[test]
|
|
||||||
fn filling_the_limit_mid_pass_still_reports_truncated() {
|
|
||||||
let p = tmp_db("limit-one");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
s.add("match.txt", "/d", 1, None);
|
|
||||||
let conn = s.done();
|
|
||||||
|
|
||||||
let options = SearchOptions {
|
|
||||||
limit: 1,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let (hits, outcome) = run_collect(&conn, "match", &options);
|
|
||||||
assert_eq!(hits.len(), 1);
|
|
||||||
assert!(
|
|
||||||
outcome.limited,
|
|
||||||
"a scan that stopped with rows unexamined says so"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stopping at the limit must not cost a result that belongs in it.
|
|
||||||
///
|
|
||||||
/// The scan breaks out as soon as the display limit is full, which is what
|
|
||||||
/// keeps a common term from decompressing its whole candidate set. The break
|
|
||||||
/// sits after classification for a reason: a *scanned* row is not a match —
|
|
||||||
/// the filename pass's SQL is a superset feeding two rank tiers — so breaking
|
|
||||||
/// on one would end the pass early over a row that was never going to be
|
|
||||||
/// shown, and the hits below it would be lost.
|
|
||||||
#[test]
|
|
||||||
fn stopping_at_the_limit_keeps_the_best_ranked_hits() {
|
|
||||||
let p = tmp_db("limit-break");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
// Exact-name matches rank above substring matches, and are seeded last so
|
|
||||||
// that a pass which stopped too early would miss them.
|
|
||||||
for i in 0..20 {
|
|
||||||
s.add(&format!("zz-match-{:02}.txt", i), "/d", 1, None);
|
|
||||||
}
|
|
||||||
s.add("match", "/d", 1, None);
|
|
||||||
let conn = s.done();
|
|
||||||
|
|
||||||
let options = SearchOptions {
|
|
||||||
limit: 2,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let (hits, outcome) = run_collect(&conn, "match", &options);
|
|
||||||
assert_eq!(hits.len(), 2);
|
|
||||||
assert!(
|
|
||||||
outcome.limited,
|
|
||||||
"21 matches under a limit of 2 is a cut set"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
hits[0].name, "match",
|
|
||||||
"the exact match must survive the break"
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn session_ignores_hide_hits_before_the_cap() {
|
fn session_ignores_hide_hits_before_the_cap() {
|
||||||
let p = tmp_db("ignores");
|
let p = tmp_db("ignores");
|
||||||
|
|
@ -910,100 +796,6 @@ fn wildcard_with_short_segments_falls_back_to_a_full_scan() {
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `LIKE` prefilter a straddling wildcard now gets must be a *superset* of
|
|
||||||
/// what the classifier accepts, or real hits vanish silently — the failure mode
|
|
||||||
/// with no symptom. Rather than assert a hand-written expected set, this seeds
|
|
||||||
/// rows that exercise every way a segment can sit across `parent || name` and
|
|
||||||
/// checks the prefiltered pass against the unfiltered one.
|
|
||||||
///
|
|
||||||
/// The unfiltered side is obtained by asking for a pattern whose segments all
|
|
||||||
/// contain a separator, which is the arm that still scans — so both sides run
|
|
||||||
/// through the real cascade and no test-only code path is involved.
|
|
||||||
#[test]
|
|
||||||
fn a_wildcard_prefilter_never_loses_a_hit_the_full_scan_finds() {
|
|
||||||
let p = tmp_db("wildprefilter");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
// A segment wholly inside the name.
|
|
||||||
let in_name = s.add("report-q3.txt", "/data", 1, None);
|
|
||||||
// A segment wholly inside the parent.
|
|
||||||
let in_parent = s.add("a.bin", "/reports/q3", 2, None);
|
|
||||||
// The pattern's `%` spanning the parent/name boundary: `rep` is in the
|
|
||||||
// directory, `q3` in the file name.
|
|
||||||
let across = s.add("q3.txt", "/reports", 3, None);
|
|
||||||
// A name that only matches once case is folded.
|
|
||||||
let folded = s.add("REPORT-Q3.TXT", "/upper", 4, None);
|
|
||||||
// A near miss that must not be admitted by either path.
|
|
||||||
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);
|
|
||||||
// The row that makes the separator-free rule load-bearing rather than
|
|
||||||
// decorative. Against `e/pq*txt` the longest segment is `e/pq`, which
|
|
||||||
// occurs in `parent || name` **only across the join** — `/a/re/` does not
|
|
||||||
// contain it and `pq.txt` does not either. Anchoring on it would drop this
|
|
||||||
// row, so the rule has to reject it and fall 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 mut got: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
|
|
||||||
got.sort();
|
|
||||||
|
|
||||||
// The same query with every segment forced to carry a separator would
|
|
||||||
// change what matches, so the reference set is computed directly: a row
|
|
||||||
// is expected exactly when the compiled pattern matches its name or its
|
|
||||||
// full path, which is what the classifier tests.
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A pattern every segment of which carries a separator has nothing to anchor
|
|
||||||
/// on, so the pass falls back to scanning — and must still find its hits.
|
|
||||||
#[test]
|
|
||||||
fn a_wildcard_with_only_separator_segments_still_scans_and_matches() {
|
|
||||||
let p = tmp_db("wildnoanchor");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
let hit = s.add("q3.txt", "/a/reports", 1, None);
|
|
||||||
let _miss = s.add("q3.txt", "/a/summaries", 2, None);
|
|
||||||
let conn = s.done();
|
|
||||||
|
|
||||||
// Segments are "a/rep" and "rts/" — both contain a separator.
|
|
||||||
let (hits, _) = run_collect(&conn, "a/rep*rts/", &SearchOptions::default());
|
|
||||||
assert_eq!(
|
|
||||||
hits.iter().map(|h| h.file_id).collect::<Vec<_>>(),
|
|
||||||
vec![hit]
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wildcard_path_tier_and_filters() {
|
fn wildcard_path_tier_and_filters() {
|
||||||
let p = tmp_db("wildpath");
|
let p = tmp_db("wildpath");
|
||||||
|
|
@ -1526,60 +1318,12 @@ fn the_connection_is_released_once_searching_stops() {
|
||||||
std::fs::remove_file(&p).ok();
|
std::fs::remove_file(&p).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An explicit release must actually leave the file closed — including when a
|
|
||||||
/// search was queued a moment before it.
|
|
||||||
///
|
|
||||||
/// The worker coalesces its request queue, so a release found behind a search
|
|
||||||
/// is handled where it sits and the search then runs. Without a cancel it
|
|
||||||
/// would run *after* the acknowledgement, reopening the index while the caller
|
|
||||||
/// — `Backend::rebuild_index` — is on its way to delete it. `release_connection`
|
|
||||||
/// therefore bumps the generation first, which is what this asserts: the
|
|
||||||
/// request made before the release is stale afterwards, so the worker drops it
|
|
||||||
/// instead of serving it.
|
|
||||||
///
|
|
||||||
/// The generation is checked rather than the descriptor because the race is
|
|
||||||
/// won or lost in microseconds; an fd assertion here would pass with the bug
|
|
||||||
/// in place most of the time and fail the rest, which is worse than no test.
|
|
||||||
#[test]
|
|
||||||
fn a_release_supersedes_a_search_queued_before_it() {
|
|
||||||
let p = tmp_db("relcancel");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
// The service still works: cancelling is how the release is made safe, not
|
|
||||||
// a way of shutting the worker down.
|
|
||||||
assert_eq!(
|
|
||||||
search_names(&service, &updates, "shared").unwrap(),
|
|
||||||
vec!["held.txt"]
|
|
||||||
);
|
|
||||||
service.shutdown();
|
|
||||||
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The point of the whole change: a pass hands hits over *while* it scans, so
|
/// The point of the whole change: a pass hands hits over *while* it scans, so
|
||||||
/// the UI has something to show long before the scan ends.
|
/// the UI has something to show long before the scan ends.
|
||||||
///
|
///
|
||||||
/// Proven by ordering rather than by batch count — `flush_pass` has always
|
/// Proven by ordering rather than by batch count — `flush_pass` has always
|
||||||
/// chunked its output, so counting sink calls proves nothing. Pass A scans in
|
/// chunked its output, so counting sink calls proves nothing. Pass A scans in
|
||||||
/// stored `(parent, name)` order, so seeding a *worse* match early and a
|
/// `files.path` order, so seeding a *worse* match at an early path and a
|
||||||
/// *better* one at a late path separates the two designs: emitting at the end
|
/// *better* one at a late path separates the two designs: emitting at the end
|
||||||
/// sorts them and leads with rank 1, while streaming hands over the rank-3 hit
|
/// sorts them and leads with rank 1, while streaming hands over the rank-3 hit
|
||||||
/// before the scan has even reached the rank-1 one.
|
/// before the scan has even reached the rank-1 one.
|
||||||
|
|
@ -1824,87 +1568,3 @@ fn a_fuzzy_mark_is_not_truncated_to_a_leading_part_of_the_term() {
|
||||||
&snip.window[a..b]
|
&snip.window[a..b]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A path-tier match that straddles the directory/name boundary must still be
|
|
||||||
/// found.
|
|
||||||
///
|
|
||||||
/// `files` stores `parent` and `name` and no concatenation of the two, so the
|
|
||||||
/// filename pass cannot prefilter with a single `path LIKE`. For a one-piece
|
|
||||||
/// term it uses `name LIKE ? OR parent LIKE ?`, which is exactly equivalent —
|
|
||||||
/// a term with no separator in it cannot span the boundary. A multi-segment
|
|
||||||
/// wildcard *can*: `doc*q3` matches `/x/docs/q3.txt` with `doc` in the parent
|
|
||||||
/// and `q3` in the name, and neither half of that OR would fire. Those fall
|
|
||||||
/// back to scanning and letting the classifier decide, and this is the fixture
|
|
||||||
/// that fails if that fallback is ever "optimised" away.
|
|
||||||
#[test]
|
|
||||||
fn a_wildcard_spanning_the_directory_boundary_is_still_found() {
|
|
||||||
let p = tmp_db("pathstraddle");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
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());
|
|
||||||
let mut ids: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
|
|
||||||
ids.sort();
|
|
||||||
let mut want = vec![straddling, in_name];
|
|
||||||
want.sort();
|
|
||||||
assert_eq!(ids, want, "the straddling path match must not be dropped");
|
|
||||||
|
|
||||||
// And it is a path-tier hit carrying the whole path as its snippet.
|
|
||||||
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");
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The one-piece prefilter's other half: a term that is a whole directory
|
|
||||||
/// component matches through `parent LIKE`, with nothing in the name.
|
|
||||||
#[test]
|
|
||||||
fn a_term_matching_only_the_directory_is_found_through_the_parent() {
|
|
||||||
let p = tmp_db("pathparent");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
let dir_hit = s.add("z.bin", "/srv/invoices", 1, None);
|
|
||||||
let _miss = s.add("z.bin", "/srv/other", 2, None);
|
|
||||||
let conn = s.done();
|
|
||||||
|
|
||||||
let (hits, _) = run_collect(&conn, "invoices", &SearchOptions::default());
|
|
||||||
assert_eq!(hits.len(), 1);
|
|
||||||
assert_eq!(hits[0].file_id, dir_hit);
|
|
||||||
assert_eq!(hits[0].stage, 9);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `folder:` must reach the folder's own files, not just its subdirectories'.
|
|
||||||
///
|
|
||||||
/// The filter is a single `parent LIKE 'dir/%'`, which covers both only
|
|
||||||
/// because every stored parent ends in a separator — the folder's own files
|
|
||||||
/// have parent `dir/`, and `%` matches nothing. It used to need a second
|
|
||||||
/// `parent = ?` term to catch them.
|
|
||||||
#[test]
|
|
||||||
fn the_folder_filter_covers_the_folder_itself_and_its_subtree() {
|
|
||||||
let p = tmp_db("folderself");
|
|
||||||
let mut s = Seeder::new(&p, true);
|
|
||||||
let own = s.add("top.txt", "/srv/data", 1, None);
|
|
||||||
let nested = s.add("deep.txt", "/srv/data/2024", 2, None);
|
|
||||||
let _sibling = s.add("other.txt", "/srv/data-archive", 3, None);
|
|
||||||
let _outside = s.add("far.txt", "/srv", 4, None);
|
|
||||||
let conn = s.done();
|
|
||||||
|
|
||||||
let (hits, _) = run_collect(&conn, "txt folder:/srv/data", &SearchOptions::default());
|
|
||||||
let mut ids: Vec<i64> = hits.iter().map(|h| h.file_id).collect();
|
|
||||||
ids.sort();
|
|
||||||
let mut want = vec![own, nested];
|
|
||||||
want.sort();
|
|
||||||
assert_eq!(ids, want, "the folder's own files count as inside it");
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&p).ok();
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -21,194 +21,6 @@ pub fn scratch_db(tag: &str) -> std::path::PathBuf {
|
||||||
scratch_dir(tag).join("index.sqlite")
|
scratch_dir(tag).join("index.sqlite")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic pseudo-random word picker — the same LCG and constants
|
|
||||||
/// `benches/corpus`, `examples/indexprobe` and `tests/search_perf` use, for the
|
|
||||||
/// same reason: a fixed seed is what makes two runs comparable, so a number
|
|
||||||
/// that moved is a real change rather than a different corpus.
|
|
||||||
pub struct Lcg(pub u64);
|
|
||||||
|
|
||||||
impl Lcg {
|
|
||||||
pub fn new(seed: u64) -> Lcg {
|
|
||||||
Lcg(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next(&mut self) -> u64 {
|
|
||||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
|
|
||||||
self.0 >> 33
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The rare term a seeded index is searched for.
|
|
||||||
///
|
|
||||||
/// Nine bytes — long enough to clear the trigram floor, and exactly
|
|
||||||
/// `3 × (2 + 1)`, so it sits on the boundary where a `fuzzy_max_edits = 2`
|
|
||||||
/// pigeonhole split into three-character chunks becomes legal.
|
|
||||||
pub const NEEDLE: &str = "quartzite";
|
|
||||||
|
|
||||||
/// A term planted only in document *bodies*, never in a file name.
|
|
||||||
///
|
|
||||||
/// The needle above reaches the index through both, so a query for it is
|
|
||||||
/// answered mostly by the filename pass. This one forces the full-text pass to
|
|
||||||
/// do real work: the filename `LIKE` finds nothing, and every candidate the
|
|
||||||
/// trigram index returns has to be decompressed and verified.
|
|
||||||
pub const BODY_TERM: &str = "chalcedony";
|
|
||||||
|
|
||||||
/// Filler vocabulary for seeded indexes.
|
|
||||||
///
|
|
||||||
/// **Deliberately shares no trigram with [`NEEDLE`]** — no filler word contains
|
|
||||||
/// so much as `qua`. That is what makes a needle query genuinely rare, and it
|
|
||||||
/// matters more than it looks: with a vocabulary that merely *resembled* the
|
|
||||||
/// needle, every query would fill the display limit within the first few
|
|
||||||
/// hundred rows, the cascade would break out of pass A, and passes B, C and D
|
|
||||||
/// would never run at all. A harness built that way reports the same figure for
|
|
||||||
/// a literal and a fuzzy search and looks perfectly healthy doing it.
|
|
||||||
pub const WORDS: &[&str] = &[
|
|
||||||
"alpha",
|
|
||||||
"beta",
|
|
||||||
"gamma",
|
|
||||||
"delta",
|
|
||||||
"epsilon",
|
|
||||||
"zeta",
|
|
||||||
"eta",
|
|
||||||
"theta",
|
|
||||||
"iota",
|
|
||||||
"kappa",
|
|
||||||
"lambda",
|
|
||||||
"brown",
|
|
||||||
"fox",
|
|
||||||
"jumps",
|
|
||||||
"lazy",
|
|
||||||
"index",
|
|
||||||
"search",
|
|
||||||
"cascade",
|
|
||||||
"snippet",
|
|
||||||
"document",
|
|
||||||
"content",
|
|
||||||
"extract",
|
|
||||||
"summary",
|
|
||||||
"meeting",
|
|
||||||
"invoice",
|
|
||||||
"contract",
|
|
||||||
"budget",
|
|
||||||
"revenue",
|
|
||||||
"planning",
|
|
||||||
"review",
|
|
||||||
"draft",
|
|
||||||
"final",
|
|
||||||
"notes",
|
|
||||||
"appendix",
|
|
||||||
"figure",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// What [`seed_index`] should build.
|
|
||||||
pub struct SeedSpec {
|
|
||||||
pub files: usize,
|
|
||||||
/// One file in every `content_every` gets extracted text. A tenth is the
|
|
||||||
/// real shape — most files in a tree are not text — and it keeps the FTS
|
|
||||||
/// index smaller than the table, as it is in practice.
|
|
||||||
pub content_every: usize,
|
|
||||||
/// Words in each stored document body.
|
|
||||||
pub body_words: usize,
|
|
||||||
/// Directories to spread the rows across, so `files.parent` has real
|
|
||||||
/// variety and `idx_files_parent` has interior levels.
|
|
||||||
pub dirs: usize,
|
|
||||||
/// File names carrying [`NEEDLE`]. Kept far below any sane display limit so
|
|
||||||
/// a needle query never fills it — an early-exiting query measures how fast
|
|
||||||
/// the cascade gives up, not how fast it scans.
|
|
||||||
pub needle_names: usize,
|
|
||||||
/// Document bodies carrying [`NEEDLE`], on top of the names.
|
|
||||||
pub needle_docs: usize,
|
|
||||||
/// Document bodies carrying [`BODY_TERM`]. Sized by the caller to stay
|
|
||||||
/// under the display limit, or the pass stops early and measures the
|
|
||||||
/// give-up rather than the verification.
|
|
||||||
pub body_term_docs: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SeedSpec {
|
|
||||||
fn default() -> SeedSpec {
|
|
||||||
SeedSpec {
|
|
||||||
files: 50_000,
|
|
||||||
content_every: 10,
|
|
||||||
// ~2 KB of text per document. Not arbitrary: the fuzzy full-text
|
|
||||||
// pass decompresses and scans every stored body, so a corpus of
|
|
||||||
// 400-byte documents makes that pass look free when in production
|
|
||||||
// it is the most expensive thing the cascade does. Still far under
|
|
||||||
// `maximum_text_size` (256 KiB), which is the real worst case.
|
|
||||||
body_words: 300,
|
|
||||||
dirs: 500,
|
|
||||||
needle_names: 50,
|
|
||||||
needle_docs: 50,
|
|
||||||
body_term_docs: 500,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seed an index with synthetic rows, in one transaction.
|
|
||||||
///
|
|
||||||
/// Shared by the measurement harnesses so they all describe the same corpus;
|
|
||||||
/// what varies between them is the size, not the shape.
|
|
||||||
pub fn seed_index(path: &Path, spec: &SeedSpec) {
|
|
||||||
use quicksearch_core::db::repo::{insert_file, set_content_done, NewFile};
|
|
||||||
use quicksearch_core::mime::FileType;
|
|
||||||
use quicksearch_core::testutil::zstd_of;
|
|
||||||
|
|
||||||
let mut conn = db::open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
let mut rng = Lcg::new(0x5eed);
|
|
||||||
// Spacing rather than a random draw, so the planted rows are spread across
|
|
||||||
// the table instead of clustering in whatever prefix the scan reaches
|
|
||||||
// first — a cluster at the front would let a pass stop early and report a
|
|
||||||
// fraction of the work a real rare query costs.
|
|
||||||
let name_stride = spec.files / spec.needle_names.max(1);
|
|
||||||
let doc_stride = spec.files / spec.needle_docs.max(1);
|
|
||||||
let body_stride = spec.files / spec.body_term_docs.max(1);
|
|
||||||
let tx = conn.transaction().unwrap();
|
|
||||||
for i in 0..spec.files {
|
|
||||||
let w1 = WORDS[(rng.next() as usize) % WORDS.len()];
|
|
||||||
let w2 = WORDS[(rng.next() as usize) % WORDS.len()];
|
|
||||||
let name = if spec.needle_names > 0 && i % name_stride.max(1) == 0 {
|
|
||||||
format!("{}-{}-{:07}.txt", w1, NEEDLE, i)
|
|
||||||
} else {
|
|
||||||
format!("{}-{}-{:07}.txt", w1, w2, i)
|
|
||||||
};
|
|
||||||
// Stored parents always end in a separator; see `dir_to_db_parent`.
|
|
||||||
let dir = format!("/seed/{:03}/", i % spec.dirs.max(1));
|
|
||||||
let id = insert_file(
|
|
||||||
&tx,
|
|
||||||
&NewFile {
|
|
||||||
name: &name,
|
|
||||||
parent: &dir,
|
|
||||||
size: 4096,
|
|
||||||
mtime: 1_700_000_000 + i as u64,
|
|
||||||
mime: Some("text/plain"),
|
|
||||||
ftype: FileType::TEXT,
|
|
||||||
hash: None,
|
|
||||||
needs_content: i % spec.content_every.max(1) == 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.expect("unique path");
|
|
||||||
if i % spec.content_every.max(1) == 0 {
|
|
||||||
let mut body: Vec<&str> = (0..spec.body_words)
|
|
||||||
.map(|_| WORDS[(rng.next() as usize) % WORDS.len()])
|
|
||||||
.collect();
|
|
||||||
if spec.needle_docs > 0 && i % doc_stride.max(1) == 0 {
|
|
||||||
// Mid-body, so a snippet window has to be cut around it rather
|
|
||||||
// than falling out of a head-of-file window for free.
|
|
||||||
body[spec.body_words / 2] = NEEDLE;
|
|
||||||
}
|
|
||||||
if spec.body_term_docs > 0 && i % body_stride.max(1) == 0 {
|
|
||||||
// Two thirds in, so verifying it means scanning most of the
|
|
||||||
// document rather than stopping at the first few bytes.
|
|
||||||
body[spec.body_words * 2 / 3] = BODY_TERM;
|
|
||||||
}
|
|
||||||
let body = body.join(" ");
|
|
||||||
set_content_done(&tx, id, &body, zstd_of(&body).as_deref()).unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tx.commit().unwrap();
|
|
||||||
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How long a single indexing run may take before the test gives up. Generous:
|
/// How long a single indexing run may take before the test gives up. Generous:
|
||||||
/// CI runs these in a container against a cold page cache.
|
/// CI runs these in a container against a cold page cache.
|
||||||
const INDEX_TIMEOUT: Duration = Duration::from_secs(120);
|
const INDEX_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
//! The audio corpus files: `.mp3` and `.flac`.
|
|
||||||
//!
|
|
||||||
//! Audio is the one format family whose "text" is not a document body but a
|
|
||||||
//! handful of tag values, so its expectation is the five fields
|
|
||||||
//! `extract::audio` concatenates, in the order it concatenates them: title,
|
|
||||||
//! artist, album, genre, comment. Getting that order wrong is a real
|
|
||||||
//! regression — the stored text would churn between runs — so the ordered
|
|
||||||
//! match is doing load-bearing work here rather than just tolerating
|
|
||||||
//! boilerplate.
|
|
||||||
//!
|
|
||||||
//! `id3` and `metaflac` write the tags; `lofty` reads them. The audio data
|
|
||||||
//! underneath is hand-rolled, since neither crate synthesises any.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::{BodyFn, Charset, Lcg, Sample};
|
|
||||||
|
|
||||||
pub fn write_all(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
mp3(dir, lcg, body, out);
|
|
||||||
flac(dir, lcg, body, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The five tag values, in `extract::audio`'s emit order.
|
|
||||||
///
|
|
||||||
/// Sentence 1 carries the needle, 2 the Latin-1 phrase and 3 the Greek, so
|
|
||||||
/// this spread also puts non-ASCII into two different tag frames — which is
|
|
||||||
/// what forces the writers off ISO-8859-1 and into a wide encoding.
|
|
||||||
fn fields(sentences: &[String]) -> [&str; 5] {
|
|
||||||
[
|
|
||||||
&sentences[0],
|
|
||||||
&sentences[1],
|
|
||||||
&sentences[2],
|
|
||||||
&sentences[3],
|
|
||||||
&sentences[4],
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Four silent MPEG-1 Layer III frames: 128 kbps, 44.1 kHz, no padding, so
|
|
||||||
/// each is 417 bytes. Four because a probe confirms a sync word by checking
|
|
||||||
/// that the next frame begins where the first one said it would.
|
|
||||||
fn mpeg_frames() -> Vec<u8> {
|
|
||||||
let mut out = Vec::with_capacity(4 * 417);
|
|
||||||
for _ in 0..4 {
|
|
||||||
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
|
|
||||||
out.resize(out.len() + 413, 0);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mp3(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
use id3::{TagLike, Version};
|
|
||||||
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let [title, artist, album, genre, comment] = fields(&b.sentences);
|
|
||||||
|
|
||||||
let path = super::write_file(dir, "track.mp3", &mpeg_frames());
|
|
||||||
|
|
||||||
let mut tag = id3::Tag::new();
|
|
||||||
tag.set_title(title);
|
|
||||||
tag.set_artist(artist);
|
|
||||||
tag.set_album(album);
|
|
||||||
tag.set_genre(genre);
|
|
||||||
tag.add_frame(id3::frame::Comment {
|
|
||||||
lang: "eng".to_string(),
|
|
||||||
description: String::new(),
|
|
||||||
text: comment.to_string(),
|
|
||||||
});
|
|
||||||
tag.write_to_path(&path, Version::Id3v23)
|
|
||||||
.expect("write id3 tag");
|
|
||||||
|
|
||||||
out.push(Sample {
|
|
||||||
path,
|
|
||||||
label: "mp3",
|
|
||||||
must_contain: fields(&b.sentences).iter().map(|s| s.to_string()).collect(),
|
|
||||||
needle: b.needle.clone(),
|
|
||||||
head_path: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fifty milliseconds of silence, committed at `tests/fixtures/silence.flac`.
|
|
||||||
///
|
|
||||||
/// Unlike MPEG — whose frames are a fixed-size header plus zeroes, cheap
|
|
||||||
/// enough to hand-roll — a FLAC frame carries CRC-8 and CRC-16 over
|
|
||||||
/// bit-packed subframes, and `lofty` reads the first one to derive the
|
|
||||||
/// stream's properties. A metadata-only file is rejected outright with
|
|
||||||
/// "failed to fill whole buffer".
|
|
||||||
///
|
|
||||||
/// So the *audio* is committed and the *tags* are still written per run: the
|
|
||||||
/// fixture carries no text at all, `metaflac` puts the seeded lipsum into it,
|
|
||||||
/// and `lofty` is still reading something a different library wrote. Produced
|
|
||||||
/// once with:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 0.05 \
|
|
||||||
/// -sample_fmt s16 -c:a flac -compression_level 12 silence.flac
|
|
||||||
/// ```
|
|
||||||
fn silence() -> std::path::PathBuf {
|
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/silence.flac")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn flac(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let [title, artist, album, genre, comment] = fields(&b.sentences);
|
|
||||||
|
|
||||||
let path = dir.join("track.flac");
|
|
||||||
std::fs::copy(silence(), &path).unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"copy {} -> {}: {e}\n(committed fixture; see the doc comment on `silence`)",
|
|
||||||
silence().display(),
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut tag = metaflac::Tag::read_from_path(&path).expect("read flac metadata");
|
|
||||||
let comments = tag.vorbis_comments_mut();
|
|
||||||
comments.set_title(vec![title]);
|
|
||||||
comments.set_artist(vec![artist]);
|
|
||||||
comments.set_album(vec![album]);
|
|
||||||
comments.set_genre(vec![genre]);
|
|
||||||
comments.set("COMMENT", vec![comment]);
|
|
||||||
tag.save().expect("write flac tags");
|
|
||||||
|
|
||||||
out.push(Sample {
|
|
||||||
path,
|
|
||||||
label: "flac",
|
|
||||||
must_contain: fields(&b.sentences).iter().map(|s| s.to_string()).collect(),
|
|
||||||
needle: b.needle.clone(),
|
|
||||||
head_path: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,142 +0,0 @@
|
||||||
//! The committed `.doc` / `.xls` / `.ppt` fixtures.
|
|
||||||
//!
|
|
||||||
//! These three are the corpus's one departure from generate-on-the-fly, and
|
|
||||||
//! the reason is narrow: `cfb` is the only Rust crate that writes OLE2
|
|
||||||
//! compound files, and `cfb` is what `extract::ole` reads them with. A fixture
|
|
||||||
//! built by the reader's own library proves only that the two agree with each
|
|
||||||
//! other. So LibreOffice writes them, once, and the output is committed —
|
|
||||||
//! see `tests/fixtures/legacy/regen.sh`.
|
|
||||||
//!
|
|
||||||
//! Two consequences worth being explicit about:
|
|
||||||
//!
|
|
||||||
//! * **Their text is fixed, not seeded.** `QUICKSEARCH_CORPUS_SEED` shakes the
|
|
||||||
//! generated half of the corpus and leaves these alone.
|
|
||||||
//! * **The expectations are read out of the committed sources**, not written
|
|
||||||
//! out here a second time. A regenerated fixture that lost a line therefore
|
|
||||||
//! fails, instead of quietly redefining what it was supposed to contain.
|
|
||||||
//!
|
|
||||||
//! What the fixtures prove that a synthetic file cannot: LibreOffice's `.doc`
|
|
||||||
//! is a real FIB with a real piece table, its `.xls` a real BIFF stream with a
|
|
||||||
//! real shared-string table, and its `.ppt` drags the master slide's
|
|
||||||
//! placeholder prompts ("Click to edit the title text format", `___PPT10`)
|
|
||||||
//! into the text alongside the content. That last one is exactly why
|
|
||||||
//! [`super::match_in_order`] asserts containment rather than equality.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::Sample;
|
|
||||||
|
|
||||||
/// The directory the fixtures live in, resolved against the crate root so the
|
|
||||||
/// test does not care what the working directory is.
|
|
||||||
pub fn dir() -> PathBuf {
|
|
||||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/legacy")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(name: &str) -> String {
|
|
||||||
let path = dir().join(name);
|
|
||||||
std::fs::read_to_string(&path).unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"read {}: {e}\n(fixtures are committed; regenerate with regen.sh)",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Non-empty, trimmed lines — the expectation for the line-per-record sources.
|
|
||||||
fn lines(source: &str) -> Vec<String> {
|
|
||||||
source
|
|
||||||
.lines()
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|l| !l.is_empty())
|
|
||||||
.map(str::to_string)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The text of every `<text:p>` in the flat-ODF deck, in document order.
|
|
||||||
///
|
|
||||||
/// A three-line hand parse rather than `quick-xml`, which is the reader's
|
|
||||||
/// library: the source is committed, its shape is fixed, and reaching for the
|
|
||||||
/// parser under test to compute the expectation would defeat the point.
|
|
||||||
fn paragraphs(source: &str) -> Vec<String> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut rest = source;
|
|
||||||
while let Some(start) = rest.find("<text:p>") {
|
|
||||||
rest = &rest[start + "<text:p>".len()..];
|
|
||||||
let Some(end) = rest.find("</text:p>") else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
out.push(rest[..end].to_string());
|
|
||||||
rest = &rest[end..];
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The needle planted in each source. Fixed rather than derived, and well
|
|
||||||
/// clear of the generated corpus's `chalcedony0000`-`chalcedony00NN` range —
|
|
||||||
/// under the trigram tokenizer "distinct" means "not a substring of another".
|
|
||||||
const NEEDLES: [(&str, &str); 3] = [
|
|
||||||
("sample.doc", "chalcedony9001"),
|
|
||||||
("sample.xls", "chalcedony9002"),
|
|
||||||
("sample.ppt", "chalcedony9003"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// The three fixtures as corpus samples.
|
|
||||||
pub fn samples() -> Vec<Sample> {
|
|
||||||
let expectations = [
|
|
||||||
("sample.doc", "doc", lines(&read("prose.txt"))),
|
|
||||||
("sample.xls", "xls", lines(&read("sheet.csv"))),
|
|
||||||
("sample.ppt", "ppt", paragraphs(&read("deck.fodp"))),
|
|
||||||
];
|
|
||||||
|
|
||||||
expectations
|
|
||||||
.into_iter()
|
|
||||||
.map(|(file, label, must_contain)| {
|
|
||||||
let needle = NEEDLES
|
|
||||||
.iter()
|
|
||||||
.find(|(f, _)| *f == file)
|
|
||||||
.expect("every fixture has a needle")
|
|
||||||
.1;
|
|
||||||
assert!(
|
|
||||||
must_contain.iter().any(|f| f.contains(needle)),
|
|
||||||
"{file}: source no longer carries {needle}; \
|
|
||||||
the end-to-end search would not be attributable"
|
|
||||||
);
|
|
||||||
Sample {
|
|
||||||
path: dir().join(file),
|
|
||||||
label,
|
|
||||||
must_contain,
|
|
||||||
needle: needle.to_string(),
|
|
||||||
// OLE2 reads a directory that can sit anywhere in the file, so
|
|
||||||
// this format never takes the walk-time buffer path.
|
|
||||||
head_path: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Copy the fixtures into `dir` so the end-to-end run indexes them alongside
|
|
||||||
/// the generated corpus. Returns the samples with their paths rewritten to the
|
|
||||||
/// copies.
|
|
||||||
///
|
|
||||||
/// Copied rather than indexed in place: the indexer walks a directory tree,
|
|
||||||
/// and pointing it at the repository would pull in whatever else lives there.
|
|
||||||
pub fn copy_into(dir: &Path) -> Vec<Sample> {
|
|
||||||
samples()
|
|
||||||
.into_iter()
|
|
||||||
.map(|sample| {
|
|
||||||
let name = sample.path.file_name().expect("fixture has a name");
|
|
||||||
let target = dir.join(name);
|
|
||||||
std::fs::copy(&sample.path, &target).unwrap_or_else(|e| {
|
|
||||||
panic!(
|
|
||||||
"copy {} -> {}: {e}",
|
|
||||||
sample.path.display(),
|
|
||||||
target.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
Sample {
|
|
||||||
path: target,
|
|
||||||
..sample
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
//! A lipsum corpus in every format QuickSearch claims to extract text from.
|
|
||||||
//!
|
|
||||||
//! # Why this exists
|
|
||||||
//!
|
|
||||||
//! The per-extractor unit tests in `src/extract/` build their fixtures with
|
|
||||||
//! the same libraries that read them back — `zip` 0.6 for the OOXML/ODF
|
|
||||||
//! containers, `cfb` for OLE2, `lopdf` (through `pdf-extract`) for PDF. That
|
|
||||||
//! is the right choice there, because those tests aim at *malformed* input and
|
|
||||||
//! need to control every byte. But it means a writer and a reader that share a
|
|
||||||
//! wrong assumption agree with each other and the test passes.
|
|
||||||
//!
|
|
||||||
//! This module is the other half: well-formed documents from *foreign*
|
|
||||||
//! producers. Every writer here is a different implementation from the reader
|
|
||||||
//! it feeds —
|
|
||||||
//!
|
|
||||||
//! | format | written by | read by |
|
|
||||||
//! |---|---|---|
|
|
||||||
//! | docx | `docx-rs` (+ `zip` 8.x) | `zip` 0.6 + `quick-xml` |
|
|
||||||
//! | xlsx | `rust_xlsxwriter` | `zip` 0.6 + `quick-xml` |
|
|
||||||
//! | pptx, odt, ods, odp | [`zipwriter`] + `format!` | `zip` 0.6 + `quick-xml` |
|
|
||||||
//! | pdf | `pdf-writer` (typst) | `pdf-extract`/`lopdf` |
|
|
||||||
//! | mp3 | `id3` over hand-rolled MPEG frames | `lofty` |
|
|
||||||
//! | flac | `metaflac` over a committed silent stream | `lofty` |
|
|
||||||
//! | rtf | hand-written control words | `rtf-parser` |
|
|
||||||
//! | plain text | `std`, plus a hand-rolled cp1252 encoder | `encoding_rs` |
|
|
||||||
//! | doc, xls, ppt | LibreOffice, committed — see [`legacy`] | `cfb` |
|
|
||||||
//!
|
|
||||||
//! Two places take committed bytes, for two different reasons, and in both the
|
|
||||||
//! *text* still comes from the generator:
|
|
||||||
//!
|
|
||||||
//! * The three legacy binaries are what nothing in Rust can fix: `cfb` is the
|
|
||||||
//! only crate that writes OLE2 compound files, and it is the reader. Those
|
|
||||||
//! are LibreOffice's output, and they are also the one part of the corpus
|
|
||||||
//! whose text is fixed rather than seeded — see
|
|
||||||
//! `tests/fixtures/legacy/README.md`.
|
|
||||||
//! * The `.flac` borrows fifty milliseconds of committed silence because
|
|
||||||
//! `lofty` reads a real audio frame to derive stream properties. The fixture
|
|
||||||
//! carries no text; `metaflac` writes the seeded lipsum into a copy of it.
|
|
||||||
//! See [`audio`].
|
|
||||||
//!
|
|
||||||
//! # Determinism
|
|
||||||
//!
|
|
||||||
//! Everything on-the-fly is generated from one LCG seeded by [`seed`], which
|
|
||||||
//! is [`DEFAULT_SEED`] unless `QUICKSEARCH_CORPUS_SEED` says otherwise. A
|
|
||||||
//! failing assertion prints the seed it ran with, so a red CI job reproduces
|
|
||||||
//! locally with one environment variable.
|
|
||||||
|
|
||||||
// Only `extraction_corpus.rs` compiles this, and it uses most but not all of
|
|
||||||
// it; the writers each expose a little more surface than any one test needs.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
pub mod audio;
|
|
||||||
pub mod legacy;
|
|
||||||
pub mod odf;
|
|
||||||
pub mod ooxml;
|
|
||||||
pub mod pdf;
|
|
||||||
pub mod plaintext;
|
|
||||||
pub mod rtf;
|
|
||||||
pub mod zipwriter;
|
|
||||||
|
|
||||||
/// The seed used when the environment says nothing. Arbitrary; what matters
|
|
||||||
/// is that it does not change between runs.
|
|
||||||
pub const DEFAULT_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
|
|
||||||
|
|
||||||
/// The active seed. Override with `QUICKSEARCH_CORPUS_SEED=<u64>` to shake the
|
|
||||||
/// corpus without editing anything.
|
|
||||||
pub fn seed() -> u64 {
|
|
||||||
match std::env::var("QUICKSEARCH_CORPUS_SEED") {
|
|
||||||
Ok(v) => v
|
|
||||||
.trim()
|
|
||||||
.parse()
|
|
||||||
.unwrap_or_else(|_| panic!("QUICKSEARCH_CORPUS_SEED must be a u64, got {v:?}")),
|
|
||||||
Err(_) => DEFAULT_SEED,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same LCG `benches/corpus/mod.rs` uses. Copied rather than shared:
|
|
||||||
/// `benches/` is not reachable from `tests/`, and the bench corpus is
|
|
||||||
/// deliberately frozen so its numbers stay comparable across runs.
|
|
||||||
pub struct Lcg(u64);
|
|
||||||
|
|
||||||
impl Lcg {
|
|
||||||
pub fn new(seed: u64) -> Lcg {
|
|
||||||
Lcg(seed)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next(&mut self) -> u64 {
|
|
||||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1);
|
|
||||||
self.0 >> 33
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pick<'a, T>(&mut self, from: &'a [T]) -> &'a T {
|
|
||||||
&from[self.next() as usize % from.len()]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filler vocabulary. Plain ASCII lowercase so it survives every encoding in
|
|
||||||
/// the corpus unchanged, and long enough that a sentence drawn from it is
|
|
||||||
/// effectively unique.
|
|
||||||
const WORDS: &[&str] = &[
|
|
||||||
"lorem",
|
|
||||||
"ipsum",
|
|
||||||
"dolor",
|
|
||||||
"consectetur",
|
|
||||||
"adipiscing",
|
|
||||||
"eiusmod",
|
|
||||||
"tempor",
|
|
||||||
"incididunt",
|
|
||||||
"labore",
|
|
||||||
"dolore",
|
|
||||||
"aliqua",
|
|
||||||
"veniam",
|
|
||||||
"nostrud",
|
|
||||||
"exercitation",
|
|
||||||
"ullamco",
|
|
||||||
"laboris",
|
|
||||||
"commodo",
|
|
||||||
"consequat",
|
|
||||||
"voluptate",
|
|
||||||
"cillum",
|
|
||||||
"occaecat",
|
|
||||||
"cupidatat",
|
|
||||||
"proident",
|
|
||||||
"officia",
|
|
||||||
"deserunt",
|
|
||||||
"mollit",
|
|
||||||
"laborum",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// A phrase every format can carry: Latin-1 representable, so it survives
|
|
||||||
/// cp1252 and the base-14 WinAnsi font the PDF writer uses.
|
|
||||||
const LATIN1_PHRASE: &str = "café résumé naïve";
|
|
||||||
|
|
||||||
/// A phrase only formats with a Unicode text model can carry.
|
|
||||||
const UNICODE_PHRASE: &str = "Καλημέρα κόσμε";
|
|
||||||
|
|
||||||
/// What characters a format's *writer* can round-trip. Gates the non-ASCII
|
|
||||||
/// coverage so the corpus is neither lax (ASCII everywhere) nor wrong
|
|
||||||
/// (demanding Greek from a WinAnsi font).
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Charset {
|
|
||||||
/// 7-bit only.
|
|
||||||
Ascii,
|
|
||||||
/// Adds [`LATIN1_PHRASE`].
|
|
||||||
Latin1,
|
|
||||||
/// Adds [`UNICODE_PHRASE`] on top.
|
|
||||||
Unicode,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many sentences every generated document carries.
|
|
||||||
const SENTENCES: usize = 6;
|
|
||||||
|
|
||||||
/// Words per sentence.
|
|
||||||
const WORDS_PER_SENTENCE: usize = 7;
|
|
||||||
|
|
||||||
/// The lipsum planted in one file, plus the token that identifies it.
|
|
||||||
///
|
|
||||||
/// Sentences are the unit of assertion for every format: prose formats write
|
|
||||||
/// one per paragraph, spreadsheets one per cell, decks one per shape. Keeping
|
|
||||||
/// the granularity identical everywhere is what lets a single `must_contain`
|
|
||||||
/// list describe them all.
|
|
||||||
pub struct Body {
|
|
||||||
pub sentences: Vec<String>,
|
|
||||||
/// Unique to this file, planted in sentence 1 and never in the file name,
|
|
||||||
/// so an end-to-end search hit is attributable to the body text.
|
|
||||||
pub needle: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What every needle starts with.
|
|
||||||
///
|
|
||||||
/// The same word as `common::BODY_TERM`, and for the same reason that constant
|
|
||||||
/// exists: a term that reaches the index only through a document body and
|
|
||||||
/// never through a file name, so a hit for it is attributable to extraction.
|
|
||||||
/// This module stays self-contained rather than naming `common` — it has no
|
|
||||||
/// other reason to depend on the shared harness — so
|
|
||||||
/// `corpus_needles_use_the_shared_body_term` in `extraction_corpus.rs` is what
|
|
||||||
/// keeps the two from drifting.
|
|
||||||
pub const NEEDLE_PREFIX: &str = "chalcedony";
|
|
||||||
|
|
||||||
impl Body {
|
|
||||||
/// Build a body for `index`-th file, carrying whatever `charset` allows.
|
|
||||||
///
|
|
||||||
/// The needle is derived from the index alone, not from the LCG: it has to
|
|
||||||
/// stay distinct from every other file's under any seed, and under the
|
|
||||||
/// trigram tokenizer "distinct" means "not a substring of another".
|
|
||||||
pub fn new(lcg: &mut Lcg, index: usize, charset: Charset) -> Body {
|
|
||||||
let needle = format!("{NEEDLE_PREFIX}{index:04}");
|
|
||||||
let mut sentences = Vec::with_capacity(SENTENCES);
|
|
||||||
for i in 0..SENTENCES {
|
|
||||||
let mut words: Vec<String> = (0..WORDS_PER_SENTENCE)
|
|
||||||
.map(|_| lcg.pick(WORDS).to_string())
|
|
||||||
.collect();
|
|
||||||
// One planted item per sentence, at a fixed position so a
|
|
||||||
// reordering bug shows up as a failed match rather than a pass.
|
|
||||||
match i {
|
|
||||||
1 => words.insert(0, needle.clone()),
|
|
||||||
2 if charset != Charset::Ascii => words.insert(3, LATIN1_PHRASE.to_string()),
|
|
||||||
3 if charset == Charset::Unicode => words.insert(3, UNICODE_PHRASE.to_string()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
sentences.push(words.join(" "));
|
|
||||||
}
|
|
||||||
Body { sentences, needle }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One corpus file plus what its extracted text must contain.
|
|
||||||
pub struct Sample {
|
|
||||||
pub path: PathBuf,
|
|
||||||
/// A label for assertion messages — the format, not the file name.
|
|
||||||
pub label: &'static str,
|
|
||||||
/// Fragments that must appear in the extracted text, in this order, with
|
|
||||||
/// anything permitted between them.
|
|
||||||
///
|
|
||||||
/// Ordered containment, not equality, and not set membership. Equality is
|
|
||||||
/// unusable: LibreOffice's `.ppt` filter drags master-slide boilerplate
|
|
||||||
/// ("Click to edit the title text format", "___PPT10") into the text
|
|
||||||
/// stream, and every spreadsheet reader puts its cell separators
|
|
||||||
/// somewhere slightly different. Set membership is too weak: text
|
|
||||||
/// assembled out of order is exactly what a mis-read Word piece table
|
|
||||||
/// produces, and that has to fail.
|
|
||||||
pub must_contain: Vec<String>,
|
|
||||||
/// Planted in the body and absent from the file name.
|
|
||||||
pub needle: String,
|
|
||||||
/// Whether this format implements `Extractor::extract_from_head` — i.e.
|
|
||||||
/// whether the walk may extract it without reopening the file. Only
|
|
||||||
/// plaintext and RTF do.
|
|
||||||
pub head_path: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Sample {
|
|
||||||
/// The prose case, where the sentences *are* the expectation.
|
|
||||||
fn prose(path: PathBuf, label: &'static str, body: &Body, head_path: bool) -> Sample {
|
|
||||||
Sample {
|
|
||||||
path,
|
|
||||||
label,
|
|
||||||
must_contain: body.sentences.clone(),
|
|
||||||
needle: body.needle.clone(),
|
|
||||||
head_path,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where `fragments` stop matching `text` as an ordered subsequence.
|
|
||||||
///
|
|
||||||
/// `Ok(())` when every fragment is found in turn. `Err` names the first one
|
|
||||||
/// that is not, which is the only diagnostic worth printing: "the text does
|
|
||||||
/// not contain X" plus where the scan had got to.
|
|
||||||
pub fn match_in_order(text: &str, fragments: &[String]) -> Result<(), String> {
|
|
||||||
let mut cursor = 0usize;
|
|
||||||
for (i, fragment) in fragments.iter().enumerate() {
|
|
||||||
match text[cursor..].find(fragment.as_str()) {
|
|
||||||
Some(at) => cursor += at + fragment.len(),
|
|
||||||
None => {
|
|
||||||
let seen = &text[..cursor.min(text.len())];
|
|
||||||
let rest = &text[cursor.min(text.len())..];
|
|
||||||
return Err(format!(
|
|
||||||
"fragment {i} not found after byte {cursor}\n \
|
|
||||||
wanted: {fragment:?}\n \
|
|
||||||
matched so far (tail): {:?}\n \
|
|
||||||
remaining text (head): {:?}",
|
|
||||||
tail(seen, 120),
|
|
||||||
head(rest, 400),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// First `n` characters of `s`, for an error message.
|
|
||||||
fn head(s: &str, n: usize) -> String {
|
|
||||||
s.chars().take(n).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Last `n` characters of `s`, for an error message.
|
|
||||||
fn tail(s: &str, n: usize) -> String {
|
|
||||||
let count = s.chars().count();
|
|
||||||
s.chars().skip(count.saturating_sub(n)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the whole corpus into one directory and return it with its samples.
|
|
||||||
///
|
|
||||||
/// The directory is a `testutil::scratch_dir`, so it survives a failing run
|
|
||||||
/// for inspection and is swept on a later day like every other test's tree.
|
|
||||||
pub fn build(tag: &str) -> (PathBuf, Vec<Sample>) {
|
|
||||||
let dir = quicksearch_core::testutil::scratch_dir(tag);
|
|
||||||
let mut lcg = Lcg::new(seed());
|
|
||||||
let mut samples = Vec::new();
|
|
||||||
let mut next = 0usize;
|
|
||||||
let mut body = |lcg: &mut Lcg, charset: Charset| {
|
|
||||||
let b = Body::new(lcg, next, charset);
|
|
||||||
next += 1;
|
|
||||||
b
|
|
||||||
};
|
|
||||||
|
|
||||||
plaintext::write_all(&dir, &mut lcg, &mut body, &mut samples);
|
|
||||||
rtf::write_all(&dir, &mut lcg, &mut body, &mut samples);
|
|
||||||
ooxml::write_all(&dir, &mut lcg, &mut body, &mut samples);
|
|
||||||
odf::write_all(&dir, &mut lcg, &mut body, &mut samples);
|
|
||||||
pdf::write_all(&dir, &mut lcg, &mut body, &mut samples);
|
|
||||||
audio::write_all(&dir, &mut lcg, &mut body, &mut samples);
|
|
||||||
// The committed OLE2 fixtures, copied in so the whole corpus is one tree.
|
|
||||||
samples.extend(legacy::copy_into(&dir));
|
|
||||||
|
|
||||||
// Every needle has to identify exactly one file, or the end-to-end search
|
|
||||||
// proves nothing. Under the trigram tokenizer that means no needle may be
|
|
||||||
// a substring of another, which a bad `Body::new` index would produce
|
|
||||||
// silently.
|
|
||||||
for (i, a) in samples.iter().enumerate() {
|
|
||||||
for b in samples.iter().skip(i + 1) {
|
|
||||||
assert!(
|
|
||||||
!a.needle.contains(&b.needle) && !b.needle.contains(&a.needle),
|
|
||||||
"needles {:?} ({}) and {:?} ({}) are not distinguishable",
|
|
||||||
a.needle,
|
|
||||||
a.label,
|
|
||||||
b.needle,
|
|
||||||
b.label
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(dir, samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The signature the per-format writers take for "give me a fresh body".
|
|
||||||
/// A closure rather than a method so the file index keeps counting across
|
|
||||||
/// modules and every needle in the corpus stays unique.
|
|
||||||
pub type BodyFn<'a> = dyn FnMut(&mut Lcg, Charset) -> Body + 'a;
|
|
||||||
|
|
||||||
/// Write `bytes` to `dir/name` and return the path.
|
|
||||||
pub fn write_file(dir: &Path, name: &str, bytes: &[u8]) -> PathBuf {
|
|
||||||
let path = dir.join(name);
|
|
||||||
std::fs::write(&path, bytes).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
//! The three OpenDocument corpus files: `.odt`, `.ods`, `.odp`.
|
|
||||||
//!
|
|
||||||
//! No Rust crate writes ODF, so these are assembled from
|
|
||||||
//! [`super::zipwriter`] and `format!` — independent of the `zip` 0.6 and
|
|
||||||
//! `quick-xml` the reader uses.
|
|
||||||
//!
|
|
||||||
//! The `mimetype` member comes first and uncompressed, as the ODF packaging
|
|
||||||
//! spec requires and as LibreOffice writes it. `extract::office` locates
|
|
||||||
//! `content.xml` by name and never looks at it, but a package that violates
|
|
||||||
//! the spec is not the thing the corpus is supposed to be testing against.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::zipwriter::{self, xml_escape, Entry};
|
|
||||||
use super::{BodyFn, Charset, Lcg, Sample};
|
|
||||||
|
|
||||||
/// Namespace declarations shared by all three documents. Only `office` and
|
|
||||||
/// `text` are load-bearing for extraction; `table` is needed for the
|
|
||||||
/// spreadsheet's grid.
|
|
||||||
const NS: &str = "xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" \
|
|
||||||
xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" \
|
|
||||||
xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" \
|
|
||||||
xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\" \
|
|
||||||
office:version=\"1.3\"";
|
|
||||||
|
|
||||||
pub fn write_all(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
odt(dir, lcg, body, out);
|
|
||||||
ods(dir, lcg, body, out);
|
|
||||||
odp(dir, lcg, body, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Package `content.xml` as an ODF container with the right `mimetype`.
|
|
||||||
fn package(dir: &Path, name: &str, mime: &str, content: &str) -> std::path::PathBuf {
|
|
||||||
let entries = [
|
|
||||||
Entry {
|
|
||||||
name: "mimetype",
|
|
||||||
body: mime.as_bytes(),
|
|
||||||
},
|
|
||||||
Entry {
|
|
||||||
name: "content.xml",
|
|
||||||
body: content.as_bytes(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
super::write_file(dir, name, &zipwriter::archive(&entries))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prose, alternating `<text:h>` and `<text:p>` and wrapping one sentence in a
|
|
||||||
/// `<text:span>`.
|
|
||||||
///
|
|
||||||
/// The span is the interesting case: `ODF_TEXT` lists `text:span` as
|
|
||||||
/// text-bearing but *not* paragraph-breaking, and the reader counts open
|
|
||||||
/// text elements rather than flagging them. A span nested in a paragraph is
|
|
||||||
/// what distinguishes those two behaviours.
|
|
||||||
fn odt(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let mut xml = String::new();
|
|
||||||
for (i, sentence) in b.sentences.iter().enumerate() {
|
|
||||||
let escaped = xml_escape(sentence);
|
|
||||||
match i % 3 {
|
|
||||||
0 => xml.push_str(&format!(
|
|
||||||
"<text:h text:outline-level=\"1\">{escaped}</text:h>"
|
|
||||||
)),
|
|
||||||
1 => xml.push_str(&format!("<text:p>{escaped}</text:p>")),
|
|
||||||
_ => xml.push_str(&format!(
|
|
||||||
"<text:p><text:span>{escaped}</text:span></text:p>"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let content = format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<office:document-content {NS}><office:body><office:text>{xml}\
|
|
||||||
</office:text></office:body></office:document-content>"
|
|
||||||
);
|
|
||||||
let path = package(
|
|
||||||
dir,
|
|
||||||
"prose.odt",
|
|
||||||
"application/vnd.oasis.opendocument.text",
|
|
||||||
&content,
|
|
||||||
);
|
|
||||||
out.push(Sample::prose(path, "odt", &b, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One sentence per cell, one cell per row — the same shape as the `.xlsx`,
|
|
||||||
/// read by `ODF_SHEET` with its `Some(' ')` cell separator.
|
|
||||||
fn ods(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let rows: String = b
|
|
||||||
.sentences
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
format!(
|
|
||||||
"<table:table-row><table:table-cell office:value-type=\"string\">\
|
|
||||||
<text:p>{}</text:p></table:table-cell></table:table-row>",
|
|
||||||
xml_escape(s)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let content = format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<office:document-content {NS}><office:body><office:spreadsheet>\
|
|
||||||
<table:table table:name=\"Corpus\">{rows}</table:table>\
|
|
||||||
</office:spreadsheet></office:body></office:document-content>"
|
|
||||||
);
|
|
||||||
let path = package(
|
|
||||||
dir,
|
|
||||||
"sheet.ods",
|
|
||||||
"application/vnd.oasis.opendocument.spreadsheet",
|
|
||||||
&content,
|
|
||||||
);
|
|
||||||
out.push(Sample::prose(path, "ods", &b, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sentences across two `<draw:page>`s, in text boxes — the shape LibreOffice
|
|
||||||
/// writes and the shape `ODF_TEXT` reads, since `.odp` and `.odt` share a spec.
|
|
||||||
fn odp(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
const PAGES: usize = 2;
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let mut xml = String::new();
|
|
||||||
// Contiguous halves rather than round-robin: the reader concatenates
|
|
||||||
// pages in document order, so this keeps the expectation equal to
|
|
||||||
// `b.sentences` and leaves the reordering check to the `.pptx`.
|
|
||||||
let per_page = b.sentences.len().div_ceil(PAGES);
|
|
||||||
for (page, chunk) in b.sentences.chunks(per_page).enumerate() {
|
|
||||||
let frames: String = chunk
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
format!(
|
|
||||||
"<draw:frame><draw:text-box><text:p>{}</text:p></draw:text-box></draw:frame>",
|
|
||||||
xml_escape(s)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
xml.push_str(&format!(
|
|
||||||
"<draw:page draw:name=\"page{}\">{frames}</draw:page>",
|
|
||||||
page + 1
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let content = format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<office:document-content {NS}><office:body><office:presentation>{xml}\
|
|
||||||
</office:presentation></office:body></office:document-content>"
|
|
||||||
);
|
|
||||||
let path = package(
|
|
||||||
dir,
|
|
||||||
"deck.odp",
|
|
||||||
"application/vnd.oasis.opendocument.presentation",
|
|
||||||
&content,
|
|
||||||
);
|
|
||||||
out.push(Sample::prose(path, "odp", &b, false));
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
//! The three OOXML corpus files: `.docx`, `.xlsx`, `.pptx`.
|
|
||||||
//!
|
|
||||||
//! `docx-rs` and `rust_xlsxwriter` are complete, independent implementations
|
|
||||||
//! of their formats — including the container, since `docx-rs` carries `zip`
|
|
||||||
//! 8.x against the 0.6 `extract::office` reads with. There is no comparable
|
|
||||||
//! crate for PowerPoint, so the `.pptx` is assembled from [`super::zipwriter`]
|
|
||||||
//! and `format!`, which is independent of both `zip` 0.6 and `quick-xml`.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::zipwriter::{self, Entry};
|
|
||||||
use super::{BodyFn, Charset, Lcg, Sample};
|
|
||||||
|
|
||||||
pub fn write_all(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
docx(dir, lcg, body, out);
|
|
||||||
xlsx(dir, lcg, body, out);
|
|
||||||
pptx(dir, lcg, body, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One paragraph per sentence, one run per paragraph — so each sentence lands
|
|
||||||
/// in a single `<w:t>` and reaches the reader contiguous.
|
|
||||||
fn docx(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
use docx_rs::*;
|
|
||||||
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let mut docx = Docx::new();
|
|
||||||
for sentence in &b.sentences {
|
|
||||||
docx = docx.add_paragraph(Paragraph::new().add_run(Run::new().add_text(sentence)));
|
|
||||||
}
|
|
||||||
let path = dir.join("prose.docx");
|
|
||||||
let file = std::fs::File::create(&path).expect("create docx");
|
|
||||||
docx.build().pack(file).expect("pack docx");
|
|
||||||
out.push(Sample::prose(path, "docx", &b, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One sentence per cell, one cell per row.
|
|
||||||
///
|
|
||||||
/// `rust_xlsxwriter` puts strings in a real `xl/sharedStrings.xml` table and
|
|
||||||
/// has the cells index into it, which is the path `extract_xlsx` exists for —
|
|
||||||
/// an inline-string writer would leave the shared-string reader untested.
|
|
||||||
fn xlsx(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
use rust_xlsxwriter::Workbook;
|
|
||||||
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let mut workbook = Workbook::new();
|
|
||||||
let sheet = workbook.add_worksheet();
|
|
||||||
for (row, sentence) in b.sentences.iter().enumerate() {
|
|
||||||
sheet
|
|
||||||
.write_string(row as u32, 0, sentence)
|
|
||||||
.expect("write cell");
|
|
||||||
}
|
|
||||||
let path = dir.join("sheet.xlsx");
|
|
||||||
workbook.save(&path).expect("save xlsx");
|
|
||||||
out.push(Sample::prose(path, "xlsx", &b, false));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sentences dealt across three slides, so `extract_pptx`'s per-slide loop and
|
|
||||||
/// its `--- New Slide ---` marker are both exercised rather than a single
|
|
||||||
/// slide's happy path.
|
|
||||||
fn pptx(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
const SLIDES: usize = 3;
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
|
|
||||||
let mut slides: Vec<String> = Vec::with_capacity(SLIDES);
|
|
||||||
for slide in 0..SLIDES {
|
|
||||||
let paragraphs: String = b
|
|
||||||
.sentences
|
|
||||||
.iter()
|
|
||||||
.skip(slide)
|
|
||||||
.step_by(SLIDES)
|
|
||||||
.map(|s| {
|
|
||||||
format!(
|
|
||||||
"<a:p><a:r><a:t>{}</a:t></a:r></a:p>",
|
|
||||||
zipwriter::xml_escape(s)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
slides.push(format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<p:sld xmlns:p=\"http://schemas.openxmlformats.org/presentationml/2006/main\" \
|
|
||||||
xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\">\
|
|
||||||
<p:cSld><p:spTree><p:sp><p:txBody>{paragraphs}</p:txBody></p:sp></p:spTree></p:cSld>\
|
|
||||||
</p:sld>"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let names: Vec<String> = (1..=SLIDES)
|
|
||||||
.map(|i| format!("ppt/slides/slide{i}.xml"))
|
|
||||||
.collect();
|
|
||||||
let mut entries = vec![Entry {
|
|
||||||
name: "[Content_Types].xml",
|
|
||||||
body: CONTENT_TYPES.as_bytes(),
|
|
||||||
}];
|
|
||||||
for (name, xml) in names.iter().zip(&slides) {
|
|
||||||
entries.push(Entry {
|
|
||||||
name,
|
|
||||||
body: xml.as_bytes(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = super::write_file(dir, "deck.pptx", &zipwriter::archive(&entries));
|
|
||||||
|
|
||||||
// Dealing round-robin means slide order, not sentence order, decides what
|
|
||||||
// the reader emits — so the expectation has to be rebuilt in the order the
|
|
||||||
// slides are read, not copied from `b.sentences`.
|
|
||||||
let mut expected = Vec::with_capacity(b.sentences.len());
|
|
||||||
for slide in 0..SLIDES {
|
|
||||||
expected.extend(b.sentences.iter().skip(slide).step_by(SLIDES).cloned());
|
|
||||||
}
|
|
||||||
out.push(Sample {
|
|
||||||
path,
|
|
||||||
label: "pptx",
|
|
||||||
must_contain: expected,
|
|
||||||
needle: b.needle.clone(),
|
|
||||||
head_path: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A `[Content_Types].xml` good enough to make the archive a real package.
|
|
||||||
/// The reader never opens it — it goes straight for `ppt/slides/slide*.xml` —
|
|
||||||
/// but a package without one is not a `.pptx`, and the corpus should not be
|
|
||||||
/// asserting against something no other tool would accept.
|
|
||||||
const CONTENT_TYPES: &str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
|
|
||||||
<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\
|
|
||||||
<Default Extension=\"xml\" ContentType=\"application/xml\"/>\
|
|
||||||
<Override PartName=\"/ppt/slides/slide1.xml\" \
|
|
||||||
ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\"/>\
|
|
||||||
<Override PartName=\"/ppt/slides/slide2.xml\" \
|
|
||||||
ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\"/>\
|
|
||||||
<Override PartName=\"/ppt/slides/slide3.xml\" \
|
|
||||||
ContentType=\"application/vnd.openxmlformats-officedocument.presentationml.slide+xml\"/>\
|
|
||||||
</Types>";
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
//! The PDF corpus file, written with `pdf-writer`.
|
|
||||||
//!
|
|
||||||
//! `pdf-writer` is typst's low-level writer. It shares no code with
|
|
||||||
//! `pdf-extract` — in particular no `lopdf`, which is what `extract::pdf`
|
|
||||||
//! parses with and what the unit tests in `src/extract/pdf.rs` build their
|
|
||||||
//! fixtures with.
|
|
||||||
//!
|
|
||||||
//! # Why the text is Latin-1
|
|
||||||
//!
|
|
||||||
//! Helvetica is one of the fourteen fonts every PDF reader ships, so no font
|
|
||||||
//! file has to be embedded and no glyph can be missing for the wrong reason.
|
|
||||||
//! Its repertoire under `WinAnsiEncoding` is cp1252 — which covers the
|
|
||||||
//! corpus's Latin-1 phrase and cannot express its Greek one. Hence
|
|
||||||
//! [`Charset::Latin1`]: demanding Greek here would be asserting against a
|
|
||||||
//! limit of the fixture rather than of the extractor.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use pdf_writer::{Content, Finish, Name, Pdf, Rect, Ref, Str};
|
|
||||||
|
|
||||||
use super::{BodyFn, Charset, Lcg, Sample};
|
|
||||||
|
|
||||||
pub fn write_all(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
let b = body(lcg, Charset::Latin1);
|
|
||||||
|
|
||||||
let catalog_id = Ref::new(1);
|
|
||||||
let page_tree_id = Ref::new(2);
|
|
||||||
let page_id = Ref::new(3);
|
|
||||||
let font_id = Ref::new(4);
|
|
||||||
let content_id = Ref::new(5);
|
|
||||||
let font_name = Name(b"F1");
|
|
||||||
|
|
||||||
let mut pdf = Pdf::new();
|
|
||||||
pdf.catalog(catalog_id).pages(page_tree_id);
|
|
||||||
pdf.pages(page_tree_id).kids([page_id]).count(1);
|
|
||||||
|
|
||||||
let mut page = pdf.page(page_id);
|
|
||||||
page.media_box(Rect::new(0.0, 0.0, 595.0, 842.0));
|
|
||||||
page.parent(page_tree_id);
|
|
||||||
page.contents(content_id);
|
|
||||||
page.resources().fonts().pair(font_name, font_id);
|
|
||||||
page.finish();
|
|
||||||
|
|
||||||
// Without an explicit encoding the font falls back to StandardEncoding,
|
|
||||||
// whose upper half is not Latin-1 at all — `é` would come back as an
|
|
||||||
// acute accent on its own.
|
|
||||||
pdf.type1_font(font_id)
|
|
||||||
.base_font(Name(b"Helvetica"))
|
|
||||||
.encoding_predefined(Name(b"WinAnsiEncoding"));
|
|
||||||
|
|
||||||
let mut content = Content::new();
|
|
||||||
content.begin_text();
|
|
||||||
content.set_font(font_name, 12.0);
|
|
||||||
// Leading set once, then one `next_line` per sentence: each sentence is a
|
|
||||||
// single `Tj` so nothing can be interleaved into the middle of one.
|
|
||||||
content.set_leading(16.0);
|
|
||||||
content.next_line(56.0, 780.0);
|
|
||||||
for (i, sentence) in b.sentences.iter().enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
content.next_line(0.0, 0.0);
|
|
||||||
}
|
|
||||||
content.show(Str(&super::plaintext::to_cp1252(sentence)));
|
|
||||||
}
|
|
||||||
content.end_text();
|
|
||||||
pdf.stream(content_id, &content.finish());
|
|
||||||
|
|
||||||
let path = super::write_file(dir, "prose.pdf", &pdf.finish());
|
|
||||||
out.push(Sample::prose(path, "pdf", &b, false));
|
|
||||||
}
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
//! Plain-text corpus files: one per extension family, plus one per encoding.
|
|
||||||
//!
|
|
||||||
//! Written with `std` alone. The one place that needs an encoder — windows-1252
|
|
||||||
//! — gets a hand-rolled one rather than `encoding_rs`, which is what
|
|
||||||
//! `textenc::decode_text` reads it back with.
|
|
||||||
//!
|
|
||||||
//! The extension sweep is not decoration: it drives all three stages of
|
|
||||||
//! `mime::guess_mime_from_head`. `.bat` comes from the override table, most
|
|
||||||
//! come from `mime_guess`, and the extensionless `README` reaches the text
|
|
||||||
//! sniff with no other evidence at all. Several of the extensions here also
|
|
||||||
//! resolve to `EXTRA_TEXT_MIMES` entries (`.json`, `.sql`, `.svg`, `.m3u`,
|
|
||||||
//! `.eml`), which the plaintext extractor claims only because it is registered
|
|
||||||
//! ahead of the audio one.
|
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use super::{BodyFn, Charset, Lcg, Sample};
|
|
||||||
|
|
||||||
/// Encode `s` as windows-1252, replacing anything the codepage cannot hold.
|
|
||||||
///
|
|
||||||
/// cp1252 is Latin-1 with 27 printable characters filled into the C1 range, so
|
|
||||||
/// the whole encoder is: identity below 0x100 except for that range, plus the
|
|
||||||
/// reverse of the table for it. Hand-rolled deliberately — `encoding_rs` is the
|
|
||||||
/// decoder under test.
|
|
||||||
///
|
|
||||||
/// Shared with [`super::pdf`], whose base-14 font is WinAnsi — the same
|
|
||||||
/// repertoire under a different name.
|
|
||||||
pub fn to_cp1252(s: &str) -> Vec<u8> {
|
|
||||||
/// The 0x80-0x9F block, in order. `\u{FFFD}` marks the five unassigned
|
|
||||||
/// slots, which nothing maps onto.
|
|
||||||
const C1: [char; 32] = [
|
|
||||||
'\u{20AC}', '\u{FFFD}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}',
|
|
||||||
'\u{2021}', '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{FFFD}',
|
|
||||||
'\u{017D}', '\u{FFFD}', '\u{FFFD}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}',
|
|
||||||
'\u{2022}', '\u{2013}', '\u{2014}', '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}',
|
|
||||||
'\u{0153}', '\u{FFFD}', '\u{017E}', '\u{0178}',
|
|
||||||
];
|
|
||||||
let mut out = Vec::with_capacity(s.len());
|
|
||||||
for c in s.chars() {
|
|
||||||
let code = c as u32;
|
|
||||||
if code < 0x80 || (0xA0..=0xFF).contains(&code) {
|
|
||||||
out.push(code as u8);
|
|
||||||
} else if let Some(i) = C1.iter().position(|&t| t == c && t != '\u{FFFD}') {
|
|
||||||
out.push(0x80 + i as u8);
|
|
||||||
} else {
|
|
||||||
out.push(b'?');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extensions that exercise a distinct route through `guess_mime_from_head`,
|
|
||||||
/// paired with a wrapper that makes the file plausible for its type.
|
|
||||||
///
|
|
||||||
/// The wrapper matters less than it looks — the plaintext extractor decodes
|
|
||||||
/// rather than parses, so nothing here is validated as JSON or XML. It is
|
|
||||||
/// there so a human opening the scratch directory sees files, not lipsum with
|
|
||||||
/// a misleading suffix.
|
|
||||||
const EXTENSIONS: &[(&str, Wrapper)] = &[
|
|
||||||
("txt", Wrapper::Raw),
|
|
||||||
("md", Wrapper::Raw),
|
|
||||||
("log", Wrapper::Raw),
|
|
||||||
("csv", Wrapper::Csv),
|
|
||||||
("html", Wrapper::Html),
|
|
||||||
("xml", Wrapper::Xml),
|
|
||||||
("svg", Wrapper::Svg),
|
|
||||||
("json", Wrapper::Json),
|
|
||||||
("yml", Wrapper::Yaml),
|
|
||||||
("sql", Wrapper::Sql),
|
|
||||||
("sh", Wrapper::Hash),
|
|
||||||
("py", Wrapper::Hash),
|
|
||||||
("rs", Wrapper::Slashes),
|
|
||||||
("ini", Wrapper::Ini),
|
|
||||||
("srt", Wrapper::Srt),
|
|
||||||
("m3u", Wrapper::M3u),
|
|
||||||
("eml", Wrapper::Eml),
|
|
||||||
// From `mime::EXTENSION_OVERRIDES`, not from `mime_guess`, which calls it
|
|
||||||
// an executable.
|
|
||||||
("bat", Wrapper::Rem),
|
|
||||||
];
|
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
enum Wrapper {
|
|
||||||
Raw,
|
|
||||||
Csv,
|
|
||||||
Html,
|
|
||||||
Xml,
|
|
||||||
Svg,
|
|
||||||
Json,
|
|
||||||
Yaml,
|
|
||||||
Sql,
|
|
||||||
Hash,
|
|
||||||
Slashes,
|
|
||||||
Ini,
|
|
||||||
Srt,
|
|
||||||
M3u,
|
|
||||||
Eml,
|
|
||||||
Rem,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Wrapper {
|
|
||||||
/// Render `sentences` in this file type's clothing. Every sentence must
|
|
||||||
/// come out contiguous and unaltered — the fragments are asserted against
|
|
||||||
/// the decoded text verbatim.
|
|
||||||
fn render(self, sentences: &[String]) -> String {
|
|
||||||
let lines = |prefix: &str| {
|
|
||||||
sentences
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("{prefix}{s}"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
};
|
|
||||||
match self {
|
|
||||||
Wrapper::Raw => sentences.join("\n"),
|
|
||||||
// One sentence per cell keeps it a single field; no sentence
|
|
||||||
// contains a comma or a quote, so no quoting is needed.
|
|
||||||
Wrapper::Csv => lines(""),
|
|
||||||
Wrapper::Html => format!(
|
|
||||||
"<!DOCTYPE html>\n<html><body>\n{}\n</body></html>",
|
|
||||||
sentences
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("<p>{s}</p>"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
),
|
|
||||||
Wrapper::Xml => format!(
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<notes>\n{}\n</notes>",
|
|
||||||
sentences
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!(" <note>{s}</note>"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
),
|
|
||||||
Wrapper::Svg => format!(
|
|
||||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"400\" height=\"200\">\n{}\n</svg>",
|
|
||||||
sentences
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, s)| format!(" <text x=\"10\" y=\"{}\">{s}</text>", 20 + i * 20))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
),
|
|
||||||
// Not `serde_json`: the sentences contain no character JSON would
|
|
||||||
// escape, and an escape would break the verbatim fragment match.
|
|
||||||
Wrapper::Json => format!(
|
|
||||||
"{{\n \"notes\": [\n{}\n ]\n}}",
|
|
||||||
sentences
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!(" \"{s}\""))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(",\n")
|
|
||||||
),
|
|
||||||
Wrapper::Yaml => format!("notes:\n{}", lines(" - ")),
|
|
||||||
Wrapper::Sql => format!(
|
|
||||||
"CREATE TABLE notes (body TEXT);\n{}",
|
|
||||||
sentences
|
|
||||||
.iter()
|
|
||||||
.map(|s| format!("INSERT INTO notes VALUES ('{s}');"))
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n")
|
|
||||||
),
|
|
||||||
Wrapper::Hash => format!("#!/bin/sh\n{}", lines("# ")),
|
|
||||||
Wrapper::Slashes => format!("fn main() {{\n{}\n}}", lines(" // ")),
|
|
||||||
Wrapper::Ini => format!("[notes]\n{}", lines("note = ")),
|
|
||||||
Wrapper::Srt => sentences
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, s)| {
|
|
||||||
format!(
|
|
||||||
"{}\n00:00:{:02},000 --> 00:00:{:02},000\n{s}\n",
|
|
||||||
i + 1,
|
|
||||||
i * 2,
|
|
||||||
i * 2 + 2
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join("\n"),
|
|
||||||
Wrapper::M3u => format!("#EXTM3U\n{}", lines("#EXTINF:-1,")),
|
|
||||||
Wrapper::Eml => format!(
|
|
||||||
"From: corpus@example.invalid\nTo: reader@example.invalid\n\
|
|
||||||
Subject: lipsum\nContent-Type: text/plain; charset=utf-8\n\n{}",
|
|
||||||
sentences.join("\n")
|
|
||||||
),
|
|
||||||
Wrapper::Rem => format!("@echo off\n{}", lines("REM ")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every plaintext sample: the extension sweep, the encoding sweep, the
|
|
||||||
/// extensionless sniff case, and one file past `hash_length`.
|
|
||||||
pub fn write_all(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
for (ext, wrapper) in EXTENSIONS {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let text = wrapper.render(&b.sentences);
|
|
||||||
let path = super::write_file(dir, &format!("prose-{ext}.{ext}"), text.as_bytes());
|
|
||||||
out.push(Sample::prose(path, ext, &b, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
// No extension at all: `mime_guess` has nothing, `infer` has nothing, and
|
|
||||||
// only `textenc::looks_like_text` can answer. UTF-8 with no BOM is the
|
|
||||||
// one class it accepts on proof rather than on evidence.
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let path = super::write_file(dir, "README", b.sentences.join("\n").as_bytes());
|
|
||||||
out.push(Sample::prose(path, "extensionless", &b, true));
|
|
||||||
|
|
||||||
write_encodings(dir, lcg, body, out);
|
|
||||||
write_oversized(dir, lcg, body, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The same prose in five encodings. All five are `.txt`, so all five reach
|
|
||||||
/// the extractor identically and any difference is `textenc`'s alone.
|
|
||||||
fn write_encodings(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
// UTF-8 with a BOM: classified by `Encoding::for_bom` before the binary
|
|
||||||
// guard ever runs.
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let mut bytes = vec![0xEF, 0xBB, 0xBF];
|
|
||||||
bytes.extend_from_slice(b.sentences.join("\n").as_bytes());
|
|
||||||
let path = super::write_file(dir, "encoding-utf8-bom.txt", &bytes);
|
|
||||||
out.push(Sample::prose(path, "utf-8 + BOM", &b, true));
|
|
||||||
|
|
||||||
// UTF-16, both endiannesses, BOM-marked. Full of NUL bytes, which is
|
|
||||||
// exactly why the BOM check has to precede the binary guard.
|
|
||||||
for (label, name, big_endian) in [
|
|
||||||
("utf-16le + BOM", "encoding-utf16le.txt", false),
|
|
||||||
("utf-16be + BOM", "encoding-utf16be.txt", true),
|
|
||||||
] {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let text = b.sentences.join("\n");
|
|
||||||
let mut bytes = if big_endian {
|
|
||||||
vec![0xFE, 0xFF]
|
|
||||||
} else {
|
|
||||||
vec![0xFF, 0xFE]
|
|
||||||
};
|
|
||||||
for unit in text.encode_utf16() {
|
|
||||||
let pair = if big_endian {
|
|
||||||
unit.to_be_bytes()
|
|
||||||
} else {
|
|
||||||
unit.to_le_bytes()
|
|
||||||
};
|
|
||||||
bytes.extend_from_slice(&pair);
|
|
||||||
}
|
|
||||||
let path = super::write_file(dir, name, &bytes);
|
|
||||||
out.push(Sample::prose(path, label, &b, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
// windows-1252: no BOM, not valid UTF-8, and decoded only because the
|
|
||||||
// `.txt` extension already established it is text. `Charset::Latin1`
|
|
||||||
// because the codepage cannot hold the Greek.
|
|
||||||
let b = body(lcg, Charset::Latin1);
|
|
||||||
let path = super::write_file(
|
|
||||||
dir,
|
|
||||||
"encoding-cp1252.txt",
|
|
||||||
&to_cp1252(&b.sentences.join("\n")),
|
|
||||||
);
|
|
||||||
out.push(Sample::prose(path, "windows-1252", &b, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A file comfortably past the default `hash_length` of 8 KiB.
|
|
||||||
///
|
|
||||||
/// Under that size the walk hands the whole buffer to `extract_from_head` and
|
|
||||||
/// the file is never reopened; over it, the content pass runs the sized on-disk
|
|
||||||
/// read instead. Both paths must produce the planted text, and only this
|
|
||||||
/// sample proves the second one does.
|
|
||||||
fn write_oversized(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let mut text = String::new();
|
|
||||||
// Padding first, so the planted sentences sit past the 8 KiB mark and a
|
|
||||||
// reader that silently stopped at the head would find none of them.
|
|
||||||
while text.len() < 12 * 1024 {
|
|
||||||
text.push_str("padding filler ligula quis bibendum auctor nisi elit\n");
|
|
||||||
}
|
|
||||||
text.push_str(&b.sentences.join("\n"));
|
|
||||||
let path = super::write_file(dir, "oversized.txt", text.as_bytes());
|
|
||||||
// `head_path` stays true: `extract_from_head` is only ever called with a
|
|
||||||
// *complete* buffer, so the agreement assertion passes it the whole file.
|
|
||||||
out.push(Sample::prose(path, "oversized text", &b, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Not part of the corpus — used by the fixture assertions to read a committed
|
|
||||||
/// source file.
|
|
||||||
pub fn read_to_string(path: &PathBuf) -> String {
|
|
||||||
std::fs::read_to_string(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
//! An RTF corpus file, written as control words by hand.
|
|
||||||
//!
|
|
||||||
//! Hand-written rather than produced by a crate because RTF is a text format
|
|
||||||
//! whose writer side is trivial, and because the only Rust RTF library in the
|
|
||||||
//! tree is `rtf-parser` — the reader under test.
|
|
||||||
//!
|
|
||||||
//! Both non-ASCII escape forms are exercised, since they are separate code
|
|
||||||
//! paths in the parser and a document from a real word processor contains
|
|
||||||
//! both: `\'hh` for anything the declared codepage holds, `\uN?` for the rest.
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use super::{BodyFn, Charset, Lcg, Sample};
|
|
||||||
|
|
||||||
/// Escape one character into RTF source.
|
|
||||||
///
|
|
||||||
/// Below 0x80 only the three structural characters need escaping. From 0x80 to
|
|
||||||
/// 0xFF the `\'hh` hex form matches the `\ansicpg1252` declared in the header.
|
|
||||||
/// Above that, `\uN` carries the UTF-16 code unit.
|
|
||||||
///
|
|
||||||
/// # Why the fallback is `\'3f` and not a literal `?`
|
|
||||||
///
|
|
||||||
/// A `\uN` escape is followed by an ANSI fallback for readers that cannot do
|
|
||||||
/// Unicode, and the spec lets that be any character. This writer emits
|
|
||||||
/// `\uN\'3f` — the escaped form of `?` — because that is what LibreOffice
|
|
||||||
/// writes, and checking a file a real producer wrote is the point of this
|
|
||||||
/// corpus.
|
|
||||||
///
|
|
||||||
/// The literal form, `\uN?`, is equally legal and equally common, and it used
|
|
||||||
/// to lose the escape and the rest of the word. That is fixed — see
|
|
||||||
/// `rtf_unicode_escapes_survive_extraction` in `tests/extraction_corpus.rs`,
|
|
||||||
/// which covers all three spellings and is where that shape is now pinned.
|
|
||||||
/// Keeping this writer on LibreOffice's form keeps the corpus a check on real
|
|
||||||
/// output rather than a second copy of that test.
|
|
||||||
fn escape(c: char, out: &mut String) {
|
|
||||||
let code = c as u32;
|
|
||||||
match c {
|
|
||||||
'\\' | '{' | '}' => {
|
|
||||||
out.push('\\');
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
_ if code < 0x80 => out.push(c),
|
|
||||||
// cp1252 and Latin-1 agree over 0xA0-0xFF, which is the whole range
|
|
||||||
// the corpus's Latin-1 phrase uses.
|
|
||||||
_ if (0xA0..=0xFF).contains(&code) => out.push_str(&format!("\\'{code:02x}")),
|
|
||||||
_ => {
|
|
||||||
// Surrogate pairs are written as two `\uN`, which is what the
|
|
||||||
// format requires: the escape carries a UTF-16 code unit, not a
|
|
||||||
// scalar value. Units above 0x7FFF are written negative.
|
|
||||||
let mut buf = [0u16; 2];
|
|
||||||
for unit in c.encode_utf16(&mut buf) {
|
|
||||||
out.push_str(&format!("\\u{}\\'3f", *unit as i16));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The RTF source for `sentences`, one paragraph each.
|
|
||||||
fn document(sentences: &[String]) -> String {
|
|
||||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
|
|
||||||
for (i, sentence) in sentences.iter().enumerate() {
|
|
||||||
// A control word swallows exactly one following space as its
|
|
||||||
// delimiter, so `\par ` puts nothing of its own into the text.
|
|
||||||
if i > 0 {
|
|
||||||
out.push_str("\\par ");
|
|
||||||
}
|
|
||||||
for c in sentence.chars() {
|
|
||||||
escape(c, &mut out);
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
}
|
|
||||||
out.push('}');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn write_all(dir: &Path, lcg: &mut Lcg, body: &mut BodyFn<'_>, out: &mut Vec<Sample>) {
|
|
||||||
let b = body(lcg, Charset::Unicode);
|
|
||||||
let path = super::write_file(dir, "prose.rtf", document(&b.sentences).as_bytes());
|
|
||||||
// RTF is the other format with an `extract_from_head`: it has no trailer
|
|
||||||
// and needs no seeking, so a complete buffer parses exactly like the file.
|
|
||||||
out.push(Sample::prose(path, "rtf", &b, true));
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
//! A minimal ZIP writer, stored (uncompressed) entries only.
|
|
||||||
//!
|
|
||||||
//! Exists so the pptx and ODF containers are not built by `zip` 0.6 — the same
|
|
||||||
//! crate `extract::office` reads them back with. It is about sixty lines of
|
|
||||||
//! well-specified structure (APPNOTE 4.3), which is a smaller thing to get
|
|
||||||
//! wrong than the agreement it is here to test.
|
|
||||||
//!
|
|
||||||
//! Stored rather than deflated because the reader accepts both (see the `zip`
|
|
||||||
//! entry in `Cargo.toml`) and stored needs no compressor. `crc32fast` supplies
|
|
||||||
//! the one field that cannot be hand-waved.
|
|
||||||
//!
|
|
||||||
//! No zip64, no data descriptors, no unicode path extra field: every corpus
|
|
||||||
//! member has an ASCII name and is a few kilobytes.
|
|
||||||
|
|
||||||
/// One member of the archive.
|
|
||||||
pub struct Entry<'a> {
|
|
||||||
pub name: &'a str,
|
|
||||||
pub body: &'a [u8],
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize `entries` into a complete `.zip`.
|
|
||||||
pub fn archive(entries: &[Entry<'_>]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
// (crc, size, local header offset) per entry, for the central directory.
|
|
||||||
let mut placed: Vec<(u32, usize, usize)> = Vec::with_capacity(entries.len());
|
|
||||||
|
|
||||||
for entry in entries {
|
|
||||||
let offset = out.len();
|
|
||||||
let crc = crc32fast::hash(entry.body);
|
|
||||||
out.extend_from_slice(b"PK\x03\x04");
|
|
||||||
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // method: stored
|
|
||||||
// A fixed DOS timestamp — 1980-01-01 00:00:00, the epoch of the
|
|
||||||
// format. Nothing reads it, and a real clock would make two runs of
|
|
||||||
// the same seed produce different bytes.
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // time
|
|
||||||
out.extend_from_slice(&0x0021u16.to_le_bytes()); // date
|
|
||||||
out.extend_from_slice(&crc.to_le_bytes());
|
|
||||||
out.extend_from_slice(&(entry.body.len() as u32).to_le_bytes()); // compressed
|
|
||||||
out.extend_from_slice(&(entry.body.len() as u32).to_le_bytes()); // uncompressed
|
|
||||||
out.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
|
||||||
out.extend_from_slice(entry.name.as_bytes());
|
|
||||||
out.extend_from_slice(entry.body);
|
|
||||||
placed.push((crc, entry.body.len(), offset));
|
|
||||||
}
|
|
||||||
|
|
||||||
let cd_start = out.len();
|
|
||||||
for (entry, (crc, size, offset)) in entries.iter().zip(&placed) {
|
|
||||||
out.extend_from_slice(b"PK\x01\x02");
|
|
||||||
out.extend_from_slice(&20u16.to_le_bytes()); // version made by
|
|
||||||
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // flags
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // method: stored
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // time
|
|
||||||
out.extend_from_slice(&0x0021u16.to_le_bytes()); // date
|
|
||||||
out.extend_from_slice(&crc.to_le_bytes());
|
|
||||||
out.extend_from_slice(&(*size as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(&(*size as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(&(entry.name.len() as u16).to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // extra len
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // comment len
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // disk number start
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // internal attrs
|
|
||||||
out.extend_from_slice(&0u32.to_le_bytes()); // external attrs
|
|
||||||
out.extend_from_slice(&(*offset as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(entry.name.as_bytes());
|
|
||||||
}
|
|
||||||
let cd_size = out.len() - cd_start;
|
|
||||||
|
|
||||||
out.extend_from_slice(b"PK\x05\x06");
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // this disk
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // disk with central directory
|
|
||||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
|
||||||
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
|
||||||
out.extend_from_slice(&(cd_size as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(&(cd_start as u32).to_le_bytes());
|
|
||||||
out.extend_from_slice(&0u16.to_le_bytes()); // comment len
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Escape `s` for XML character data.
|
|
||||||
///
|
|
||||||
/// The corpus plants `&` and `<` nowhere by default, but the writers below
|
|
||||||
/// route every piece of body text through this so a future sentence
|
|
||||||
/// containing one cannot silently produce an unparseable container.
|
|
||||||
pub fn xml_escape(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len());
|
|
||||||
for c in s.chars() {
|
|
||||||
match c {
|
|
||||||
'&' => out.push_str("&"),
|
|
||||||
'<' => out.push_str("<"),
|
|
||||||
'>' => out.push_str(">"),
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
@ -91,12 +91,12 @@ fn encrypted_index_lifecycle() {
|
||||||
{
|
{
|
||||||
let conn = db::open_existing(&db_path.to_string_lossy(), true).unwrap();
|
let conn = db::open_existing(&db_path.to_string_lossy(), true).unwrap();
|
||||||
conn.execute_batch(
|
conn.execute_batch(
|
||||||
"INSERT INTO files (name, parent, size, mtime, type, content_state)
|
"INSERT INTO files (name, path, parent, size, mtime, type, content_state)
|
||||||
WITH RECURSIVE n(i) AS (
|
WITH RECURSIVE n(i) AS (
|
||||||
SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000
|
SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000
|
||||||
)
|
)
|
||||||
SELECT 'p' || i, '/pad/', 0, 0, 0, 3 FROM n;
|
SELECT 'p' || i, '/pad/' || i, '/pad', 0, 0, 0, 3 FROM n;
|
||||||
DELETE FROM files WHERE parent = '/pad/';",
|
DELETE FROM files WHERE parent = '/pad';",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
|
|
||||||
|
|
@ -1,337 +0,0 @@
|
||||||
//! End-to-end extraction coverage across every format QuickSearch claims.
|
|
||||||
//!
|
|
||||||
//! The corpus itself — what is written, by which library, and why that library
|
|
||||||
//! rather than the reader's own — is documented in [`corpus`]. This file is
|
|
||||||
//! only the assertions.
|
|
||||||
//!
|
|
||||||
//! Three layers, deliberately, because they fail for different reasons:
|
|
||||||
//!
|
|
||||||
//! 1. [`every_format_extracts_its_planted_text`] goes through
|
|
||||||
//! `mime::guess_mime_from_head` and `extract::Registry`, so a file typed
|
|
||||||
//! wrongly and a file parsed wrongly are both caught, and the failure
|
|
||||||
//! message says which.
|
|
||||||
//! 2. [`head_extraction_agrees_with_reading_the_file`] pins the walk-time
|
|
||||||
//! shortcut against the content-pass path, in both directions.
|
|
||||||
//! 3. [`the_whole_corpus_indexes_and_is_searchable`] is the product claim: the
|
|
||||||
//! text reached FTS5 and a user typing a word from the document finds it.
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
mod corpus;
|
|
||||||
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
|
|
||||||
use quicksearch_core::config::Config;
|
|
||||||
use quicksearch_core::extract::Registry;
|
|
||||||
use quicksearch_core::mime;
|
|
||||||
use quicksearch_core::query::split_for_cascade;
|
|
||||||
use quicksearch_core::search::cascade;
|
|
||||||
use quicksearch_core::search::SearchOptions;
|
|
||||||
|
|
||||||
use corpus::Sample;
|
|
||||||
|
|
||||||
/// How much of a file the MIME sniff is shown. The same default the indexer
|
|
||||||
/// uses, so dispatch here matches dispatch in a real run.
|
|
||||||
const HEAD: usize = 8 * 1024;
|
|
||||||
|
|
||||||
/// Read the leading [`HEAD`] bytes, which is what the walk hands the sniff.
|
|
||||||
fn head_of(path: &Path) -> Vec<u8> {
|
|
||||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
|
|
||||||
bytes[..bytes.len().min(HEAD)].to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the file is small enough that the walk would extract it from the
|
|
||||||
/// buffer it already hashed, rather than reopening it.
|
|
||||||
fn fits_in_head(path: &Path) -> bool {
|
|
||||||
std::fs::metadata(path)
|
|
||||||
.map(|m| m.len() as usize)
|
|
||||||
.unwrap_or(usize::MAX)
|
|
||||||
<= HEAD
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefix every failure with the seed, so a red CI job reproduces locally.
|
|
||||||
fn ctx(sample: &Sample) -> String {
|
|
||||||
format!(
|
|
||||||
"[{} | {} | QUICKSEARCH_CORPUS_SEED={}]",
|
|
||||||
sample.label,
|
|
||||||
sample.path.display(),
|
|
||||||
corpus::seed()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every corpus file is typed, claimed by an extractor, and yields its planted
|
|
||||||
/// lipsum in order.
|
|
||||||
#[test]
|
|
||||||
fn every_format_extracts_its_planted_text() {
|
|
||||||
let (_dir, samples) = corpus::build("corpus-extract");
|
|
||||||
let registry = Registry::default_set();
|
|
||||||
|
|
||||||
for sample in &samples {
|
|
||||||
let head = head_of(&sample.path);
|
|
||||||
let mime = mime::guess_mime_from_head(&sample.path, &head)
|
|
||||||
.unwrap_or_else(|| panic!("{} no MIME resolved", ctx(sample)));
|
|
||||||
assert!(
|
|
||||||
registry.supports(&mime),
|
|
||||||
"{} MIME {mime:?} is claimed by no extractor",
|
|
||||||
ctx(sample)
|
|
||||||
);
|
|
||||||
|
|
||||||
let content = registry
|
|
||||||
.extract(&sample.path, &mime)
|
|
||||||
.unwrap_or_else(|e| panic!("{} extraction failed: {e}", ctx(sample)))
|
|
||||||
.unwrap_or_else(|| panic!("{} MIME {mime:?} dispatched nowhere", ctx(sample)));
|
|
||||||
|
|
||||||
if let Err(why) = corpus::match_in_order(&content.text, &sample.must_contain) {
|
|
||||||
panic!("{} extracted text is wrong\n{why}", ctx(sample));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A corpus that silently stopped generating anything would pass every
|
|
||||||
// assertion above.
|
|
||||||
assert!(
|
|
||||||
samples.len() >= 35,
|
|
||||||
"corpus shrank to {} samples",
|
|
||||||
samples.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The walk-time buffer path and the content-pass path agree, and only the
|
|
||||||
/// formats that can support the shortcut take it.
|
|
||||||
#[test]
|
|
||||||
fn head_extraction_agrees_with_reading_the_file() {
|
|
||||||
let (_dir, samples) = corpus::build("corpus-head");
|
|
||||||
let registry = Registry::default_set();
|
|
||||||
let mut opted_in = 0;
|
|
||||||
|
|
||||||
for sample in &samples {
|
|
||||||
let head = head_of(&sample.path);
|
|
||||||
let mime = mime::guess_mime_from_head(&sample.path, &head).expect("MIME");
|
|
||||||
let whole = std::fs::read(&sample.path).expect("read whole file");
|
|
||||||
let from_head = registry.extract_complete_head(&sample.path, &mime, &whole);
|
|
||||||
|
|
||||||
if !sample.head_path {
|
|
||||||
// A format that seeks, or reads a trailer, must never be handed a
|
|
||||||
// buffer — `None` here is what routes it back to the on-disk
|
|
||||||
// extractor rather than to a wrong answer.
|
|
||||||
assert!(
|
|
||||||
from_head.is_none(),
|
|
||||||
"{} took the head path but cannot support it",
|
|
||||||
ctx(sample)
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
opted_in += 1;
|
|
||||||
|
|
||||||
let from_head = from_head
|
|
||||||
.unwrap_or_else(|| panic!("{} declined the head path", ctx(sample)))
|
|
||||||
.unwrap_or_else(|e| panic!("{} head extraction failed: {e}", ctx(sample)));
|
|
||||||
let from_disk = registry
|
|
||||||
.extract(&sample.path, &mime)
|
|
||||||
.expect("on-disk extraction")
|
|
||||||
.expect("claimed");
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
from_head.text,
|
|
||||||
from_disk.text,
|
|
||||||
"{} head and disk extraction disagree",
|
|
||||||
ctx(sample)
|
|
||||||
);
|
|
||||||
|
|
||||||
// The claim is only interesting where the walk would really take the
|
|
||||||
// shortcut. `oversized.txt` is the deliberate exception.
|
|
||||||
if fits_in_head(&sample.path) {
|
|
||||||
assert!(
|
|
||||||
corpus::match_in_order(&from_head.text, &sample.must_contain).is_ok(),
|
|
||||||
"{} head extraction lost the planted text",
|
|
||||||
ctx(sample)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
opted_in >= 24,
|
|
||||||
"only {opted_in} samples exercised the head path"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The whole corpus indexes, and each file is findable by a word that appears
|
|
||||||
/// only in its body.
|
|
||||||
#[test]
|
|
||||||
fn the_whole_corpus_indexes_and_is_searchable() {
|
|
||||||
let (dir, samples) = corpus::build("corpus-index");
|
|
||||||
// The database goes in its own directory, not the one being walked. Left
|
|
||||||
// inside, SQLite's `-wal` and `-shm` sidecars appear under the root and
|
|
||||||
// survive only because a default ignore pattern happens to prune them —
|
|
||||||
// which is a dependency this test has no reason to take on.
|
|
||||||
let db = common::scratch_db("corpus-index-db");
|
|
||||||
let config = Config::default();
|
|
||||||
|
|
||||||
common::IndexOnce {
|
|
||||||
db: &db,
|
|
||||||
roots: vec![dir.to_string_lossy().into_owned()],
|
|
||||||
config: &config,
|
|
||||||
fresh_marker: false,
|
|
||||||
encrypted: false,
|
|
||||||
}
|
|
||||||
.run();
|
|
||||||
|
|
||||||
let conn = rusqlite::Connection::open(&db).expect("open index");
|
|
||||||
for sample in &samples {
|
|
||||||
let hits = search(&conn, &sample.needle);
|
|
||||||
let paths: Vec<&str> = hits.iter().map(|p| p.as_str()).collect();
|
|
||||||
assert_eq!(
|
|
||||||
hits.len(),
|
|
||||||
1,
|
|
||||||
"{} searching {:?} returned {:?}",
|
|
||||||
ctx(sample),
|
|
||||||
sample.needle,
|
|
||||||
paths
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
Path::new(&hits[0]) == sample.path,
|
|
||||||
"{} searching {:?} found {:?}",
|
|
||||||
ctx(sample),
|
|
||||||
sample.needle,
|
|
||||||
hits[0]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every result path for `term`, run through the same cascade the GUI uses.
|
|
||||||
fn search(conn: &rusqlite::Connection, term: &str) -> Vec<String> {
|
|
||||||
let split = split_for_cascade(term).expect("split");
|
|
||||||
let latest = AtomicU64::new(1);
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
cascade::run(
|
|
||||||
conn,
|
|
||||||
&split,
|
|
||||||
&SearchOptions::default(),
|
|
||||||
1,
|
|
||||||
&latest,
|
|
||||||
&mut |batch| {
|
|
||||||
for hit in batch {
|
|
||||||
paths.push(hit.path.clone());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("cascade run")
|
|
||||||
.expect("not cancelled");
|
|
||||||
paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two RTF lexer bugs this corpus found, now fixed in `vendor/rtf-parser`.
|
|
||||||
///
|
|
||||||
/// Both came from a lexer that decided where a control word ends by looking
|
|
||||||
/// for whitespace, and then trimmed leading spaces off whatever followed. The
|
|
||||||
/// second reproduced on a file LibreOffice wrote.
|
|
||||||
///
|
|
||||||
/// **A `\uN` escape with a literal fallback took the rest of the word with
|
|
||||||
/// it.** The escape is followed by an ANSI fallback character for readers that
|
|
||||||
/// predate Unicode, and the spec allows any character there. Terminating a
|
|
||||||
/// control word at whitespace only meant `before \u233?after end` lexed
|
|
||||||
/// `\u233?after` as one unrecognised control word and yielded `"before end"` —
|
|
||||||
/// the accented character gone, and `after` with it. The loss ran to the next
|
|
||||||
/// space. The spellings that put a backslash where the lexer needed a boundary
|
|
||||||
/// — LibreOffice's `\uN\'3f` and Word's `\uN\'hh` — were unaffected.
|
|
||||||
///
|
|
||||||
/// **A space between two escaped words was dropped.** After a `\'hh` escape
|
|
||||||
/// the lexer re-tokenised the remainder, trimming its leading spaces before
|
|
||||||
/// deciding what it was. A plain-text remainder kept the untrimmed slice and
|
|
||||||
/// survived; one beginning with another control word did not. So
|
|
||||||
/// `Καλημέρα κόσμε` — every character escaped on both sides of the space —
|
|
||||||
/// came back as `Καλημέρακόσμε`, two words collapsed into one FTS term. That
|
|
||||||
/// hit every script outside cp1252: Greek, Cyrillic, Hebrew, CJK. Latin-1 text
|
|
||||||
/// was fine, because there the escapes sit *inside* words (`caf\'e9`) and the
|
|
||||||
/// space that follows is plain text.
|
|
||||||
///
|
|
||||||
/// The fixes are `StrUtils::split_control_word` and the `\'hh` arm of
|
|
||||||
/// `Lexer::tokenize`, both marked LOCAL PATCH; the fallback characters are now
|
|
||||||
/// counted off against `\ucN` in the parser rather than guessed at. See the
|
|
||||||
/// `[patch.crates-io]` note in the workspace manifest.
|
|
||||||
#[test]
|
|
||||||
fn rtf_unicode_escapes_survive_extraction() {
|
|
||||||
let dir = quicksearch_core::testutil::scratch_dir("rtf-escapes");
|
|
||||||
let extract = |name: &str, body: &str| {
|
|
||||||
use quicksearch_core::extract::Extractor;
|
|
||||||
let path = dir.join(name);
|
|
||||||
std::fs::write(&path, body).unwrap();
|
|
||||||
quicksearch_core::extract::rtf::RtfExtractor
|
|
||||||
.extract(&path)
|
|
||||||
.unwrap_or_else(|e| panic!("{name}: {e}"))
|
|
||||||
.text
|
|
||||||
};
|
|
||||||
|
|
||||||
// The literal fallback. Both halves matter: the escape survives, and so
|
|
||||||
// does the word it used to swallow.
|
|
||||||
let text = extract("literal.rtf", r"{\rtf1\ansi before \u233?after end}");
|
|
||||||
assert!(text.contains("before éafter end"), "{text:?}");
|
|
||||||
// And the fallback itself is *not* text. It repeats the character for
|
|
||||||
// readers that cannot do Unicode; indexing it would put a `?` inside every
|
|
||||||
// word containing a non-cp1252 character.
|
|
||||||
assert!(!text.contains('?'), "fallback character indexed: {text:?}");
|
|
||||||
|
|
||||||
// The same document in the two spellings that always worked, to pin that
|
|
||||||
// counting fallbacks did not break the ones the old mask handled.
|
|
||||||
for (label, body) in [
|
|
||||||
("libreoffice", r"{\rtf1\ansi before \u233\'3fafter end}"),
|
|
||||||
("word", r"{\rtf1\ansi before \u233\'e9after end}"),
|
|
||||||
] {
|
|
||||||
let text = extract(&format!("{label}.rtf"), body);
|
|
||||||
assert!(text.contains("before éafter end"), "{label}: {text:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// `\ucN` is the fallback count, and it is not always one.
|
|
||||||
let text = extract("uc2.rtf", r"{\rtf1\ansi\uc2 a\u233?!b}");
|
|
||||||
assert!(text.contains("aéb"), "two fallbacks not skipped: {text:?}");
|
|
||||||
let text = extract("uc0.rtf", r"{\rtf1\ansi\uc0 a\u233?b}");
|
|
||||||
assert!(
|
|
||||||
text.contains("aé?b"),
|
|
||||||
"\\uc0 means no fallback, so the `?` is real text: {text:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Exactly the bytes LibreOffice writes for `Καλημέρα κόσμε`.
|
|
||||||
let text = extract(
|
|
||||||
"escaped-words.rtf",
|
|
||||||
concat!(
|
|
||||||
r"{\rtf1\ansi ",
|
|
||||||
r"\u922\'3f\u945\'3f\u955\'3f\u951\'3f",
|
|
||||||
r"\u956\'3f\u941\'3f\u961\'3f\u945\'3f",
|
|
||||||
" ",
|
|
||||||
r"\u954\'3f\u972\'3f\u963\'3f\u956\'3f\u949\'3f",
|
|
||||||
"}"
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
text.contains("Καλημέρα κόσμε"),
|
|
||||||
"the space between two fully escaped words was dropped: {text:?}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A `\'hh` that is nobody's fallback is still text, and a space after it
|
|
||||||
// is still a space — the case the second fix must not overshoot.
|
|
||||||
let text = extract("standalone-hex.rtf", r"{\rtf1\ansi caf\'e9 \'e0 noon}");
|
|
||||||
assert!(text.contains("café à noon"), "{text:?}");
|
|
||||||
|
|
||||||
// A surrogate pair is one character, and `\uc1` puts a fallback between
|
|
||||||
// its halves. U+1F600, written the way Word writes it.
|
|
||||||
let text = extract("astral.rtf", r"{\rtf1\ansi a\u-10179?\u-8704?b}");
|
|
||||||
assert!(
|
|
||||||
text.contains("a\u{1F600}b"),
|
|
||||||
"surrogate pair lost: {text:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The corpus's needles and the shared harness's body term are the same word,
|
|
||||||
/// deliberately.
|
|
||||||
///
|
|
||||||
/// `common::BODY_TERM` exists because a term that reaches the index through a
|
|
||||||
/// file *name* as well as a body is answered mostly by the filename pass — so
|
|
||||||
/// it plants one that only ever appears in a body. The corpus needs exactly
|
|
||||||
/// that property, per file, for its end-to-end search to be attributable to
|
|
||||||
/// extraction rather than to the name.
|
|
||||||
///
|
|
||||||
/// `corpus` does not name `common` (it has no other reason to depend on the
|
|
||||||
/// harness), so this is what stops the two drifting apart silently.
|
|
||||||
#[test]
|
|
||||||
fn corpus_needles_use_the_shared_body_term() {
|
|
||||||
assert_eq!(corpus::NEEDLE_PREFIX, common::BODY_TERM);
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
# Legacy Office fixtures
|
|
||||||
|
|
||||||
`sample.doc`, `sample.xls` and `sample.ppt`, written by LibreOffice and
|
|
||||||
committed. Read by `tests/extraction_corpus.rs` through the corpus module in
|
|
||||||
`tests/corpus/legacy.rs`.
|
|
||||||
|
|
||||||
## Why these three are committed when the rest of the corpus is generated
|
|
||||||
|
|
||||||
Every other format in the extraction corpus is written at test time by a
|
|
||||||
library that is *not* the one that reads it back — the whole point being that a
|
|
||||||
fixture built with the reader's own library can only prove the two agree with
|
|
||||||
each other. The pre-2007 binary formats have no such library: `cfb` is the only
|
|
||||||
Rust crate that writes OLE2 compound files, and `cfb` is what `extract::ole`
|
|
||||||
reads them with.
|
|
||||||
|
|
||||||
So a foreign producer writes them once, here, and the output is committed.
|
|
||||||
That buys more than independence — a LibreOffice `.doc` is a real FIB with a
|
|
||||||
real piece table, its `.xls` a real BIFF stream with a real `SST`, and its
|
|
||||||
`.ppt` drags the master slide's placeholder prompts (`Click to edit the title
|
|
||||||
text format`, `___PPT10`) into the text stream alongside the content. None of
|
|
||||||
those shapes come out of a minimal synthetic fixture, and the last one is why
|
|
||||||
the corpus asserts ordered *containment* rather than equality.
|
|
||||||
|
|
||||||
The unit tests in `src/extract/ole_tests.rs` are the complement, not a
|
|
||||||
duplicate: they build deliberately malformed streams to check bounds and error
|
|
||||||
paths, which needs byte-level control that only `cfb` gives.
|
|
||||||
|
|
||||||
## Files
|
|
||||||
|
|
||||||
| file | role |
|
|
||||||
|---|---|
|
|
||||||
| `prose.txt` | source for `sample.doc` |
|
|
||||||
| `sheet.csv` | source for `sample.xls` — one cell per row, no commas |
|
|
||||||
| `deck.fodp` | source for `sample.ppt` — flat ODF, so the text is reviewable |
|
|
||||||
| `sample.doc` `sample.xls` `sample.ppt` | LibreOffice output, committed |
|
|
||||||
| `regen.sh` | regenerates the three from the sources |
|
|
||||||
|
|
||||||
The sources are the source of truth: `legacy.rs` reads the expected fragments
|
|
||||||
out of them rather than restating the text, so a regenerated fixture that
|
|
||||||
dropped a line fails the test instead of quietly redefining what it should
|
|
||||||
contain. Each source carries a needle (`chalcedony9001`-`chalcedony9003`) that
|
|
||||||
the end-to-end search looks for; `legacy.rs` asserts it is still present.
|
|
||||||
|
|
||||||
## Regenerating
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./regen.sh # needs libreoffice on PATH; last run with 26.2.4.2
|
|
||||||
cargo test -p quicksearch-core --test extraction_corpus
|
|
||||||
```
|
|
||||||
|
|
||||||
The output is **not** byte-reproducible — LibreOffice stamps a creation time
|
|
||||||
into each file — so re-running changes the bytes without changing the text.
|
|
||||||
Commit the result only when the *sources* changed; the test is what says the
|
|
||||||
files are still right.
|
|
||||||
|
|
||||||
`sample.ppt` is ~460 KB because the PowerPoint 97 export filter embeds the
|
|
||||||
master slide. There is no filter option that trims it, and it is well inside
|
|
||||||
the 2 MiB `maximum_text_file_size` the end-to-end run indexes with.
|
|
||||||
|
|
||||||
Unlike the generated half of the corpus, the text in these three is fixed:
|
|
||||||
`QUICKSEARCH_CORPUS_SEED` does not affect them.
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- Flat ODF presentation: the human-readable source `regen.sh` converts into
|
|
||||||
sample.ppt. Kept in this form rather than as a binary .odp so a reviewer
|
|
||||||
can see exactly what text the fixture is supposed to contain. -->
|
|
||||||
<office:document
|
|
||||||
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
|
|
||||||
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
|
|
||||||
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
|
|
||||||
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
|
|
||||||
office:version="1.3"
|
|
||||||
office:mimetype="application/vnd.oasis.opendocument.presentation">
|
|
||||||
<office:body>
|
|
||||||
<office:presentation>
|
|
||||||
<draw:page draw:name="page1">
|
|
||||||
<draw:frame svg:width="20cm" svg:height="3cm" svg:x="2cm" svg:y="2cm">
|
|
||||||
<draw:text-box><text:p>Lorem ipsum dolor sit amet consectetur</text:p></draw:text-box>
|
|
||||||
</draw:frame>
|
|
||||||
<draw:frame svg:width="20cm" svg:height="3cm" svg:x="2cm" svg:y="6cm">
|
|
||||||
<draw:text-box><text:p>The needle chalcedony9003 marks this deck</text:p></draw:text-box>
|
|
||||||
</draw:frame>
|
|
||||||
<draw:frame svg:width="20cm" svg:height="3cm" svg:x="2cm" svg:y="10cm">
|
|
||||||
<draw:text-box><text:p>Tempor incididunt café résumé naïve dolore</text:p></draw:text-box>
|
|
||||||
</draw:frame>
|
|
||||||
</draw:page>
|
|
||||||
<draw:page draw:name="page2">
|
|
||||||
<draw:frame svg:width="20cm" svg:height="3cm" svg:x="2cm" svg:y="2cm">
|
|
||||||
<draw:text-box><text:p>Ut enim ad Καλημέρα κόσμε veniam quis</text:p></draw:text-box>
|
|
||||||
</draw:frame>
|
|
||||||
<draw:frame svg:width="20cm" svg:height="3cm" svg:x="2cm" svg:y="6cm">
|
|
||||||
<draw:text-box><text:p>Ullamco laboris nisi ut aliquip commodo</text:p></draw:text-box>
|
|
||||||
</draw:frame>
|
|
||||||
<draw:frame svg:width="20cm" svg:height="3cm" svg:x="2cm" svg:y="10cm">
|
|
||||||
<draw:text-box><text:p>Irure dolor in reprehenderit voluptate velit</text:p></draw:text-box>
|
|
||||||
</draw:frame>
|
|
||||||
</draw:page>
|
|
||||||
</office:presentation>
|
|
||||||
</office:body>
|
|
||||||
</office:document>
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod.
|
|
||||||
The needle chalcedony9001 marks this document for the end-to-end search.
|
|
||||||
Tempor incididunt ut labore café résumé naïve et dolore magna aliqua.
|
|
||||||
Ut enim ad minim Καλημέρα κόσμε veniam quis nostrud exercitation.
|
|
||||||
Ullamco laboris nisi ut aliquip ex ea commodo consequat duis aute.
|
|
||||||
Irure dolor in reprehenderit in voluptate velit esse cillum dolore.
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
# Regenerate the committed .doc/.xls/.ppt fixtures from their sources.
|
|
||||||
#
|
|
||||||
# These three are the one corner of the extraction corpus that cannot be built
|
|
||||||
# on the fly: `cfb` is the only Rust crate that writes OLE2 compound files, and
|
|
||||||
# `cfb` is what `extract::ole` reads them with — a fixture built by the reader's
|
|
||||||
# own library can only prove the two agree with each other. So a foreign
|
|
||||||
# producer writes them once, here, and the output is committed.
|
|
||||||
#
|
|
||||||
# Requires LibreOffice on PATH. Last run with 26.2.4.2.
|
|
||||||
#
|
|
||||||
# The output is NOT byte-reproducible: LibreOffice stamps a creation time into
|
|
||||||
# each file. Re-running this changes the bytes without changing the text, so
|
|
||||||
# commit the result only when the *sources* changed. `cargo test -p
|
|
||||||
# quicksearch-core --test extraction_corpus` is what says the files are still
|
|
||||||
# right.
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
||||||
work=$(mktemp -d)
|
|
||||||
trap 'rm -rf "$work"' EXIT
|
|
||||||
|
|
||||||
convert() {
|
|
||||||
# -env:UserInstallation keeps this off the invoking user's LibreOffice
|
|
||||||
# profile, which also lets it run when a desktop LibreOffice is open.
|
|
||||||
libreoffice --headless \
|
|
||||||
-env:UserInstallation="file://$work/profile" \
|
|
||||||
--convert-to "$1" --outdir "$work" "$2" >/dev/null
|
|
||||||
}
|
|
||||||
|
|
||||||
convert doc "$here/prose.txt"
|
|
||||||
convert xls "$here/sheet.csv"
|
|
||||||
convert ppt "$here/deck.fodp"
|
|
||||||
|
|
||||||
mv "$work/prose.doc" "$here/sample.doc"
|
|
||||||
mv "$work/sheet.xls" "$here/sample.xls"
|
|
||||||
mv "$work/deck.ppt" "$here/sample.ppt"
|
|
||||||
|
|
||||||
ls -l "$here/sample.doc" "$here/sample.xls" "$here/sample.ppt"
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,6 +0,0 @@
|
||||||
Lorem ipsum dolor sit amet consectetur
|
|
||||||
The needle chalcedony9002 marks this sheet
|
|
||||||
Tempor incididunt café résumé naïve dolore
|
|
||||||
Ut enim ad Καλημέρα κόσμε veniam quis
|
|
||||||
Ullamco laboris nisi ut aliquip commodo
|
|
||||||
Irure dolor in reprehenderit voluptate velit
|
|
||||||
|
BIN
crates/quicksearch-core/tests/fixtures/silence.flac
vendored
BIN
crates/quicksearch-core/tests/fixtures/silence.flac
vendored
Binary file not shown.
|
|
@ -47,7 +47,7 @@ fn index_once(root: &Path, db: &Path, config: &Config) {
|
||||||
fn rows(db: &Path) -> Vec<(String, i64, i64)> {
|
fn rows(db: &Path) -> Vec<(String, i64, i64)> {
|
||||||
let conn = rusqlite::Connection::open(db).unwrap();
|
let conn = rusqlite::Connection::open(db).unwrap();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare("SELECT parent || name, mtime, content_state FROM files ORDER BY parent, name")
|
.prepare("SELECT path, mtime, content_state FROM files ORDER BY path")
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let out = stmt
|
let out = stmt
|
||||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))
|
||||||
|
|
@ -756,11 +756,11 @@ fn content_rows(db: &Path) -> Vec<ContentRow> {
|
||||||
let conn = rusqlite::Connection::open(db).unwrap();
|
let conn = rusqlite::Connection::open(db).unwrap();
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT f.parent || f.name, f.content_state, ff.reason, LENGTH(d.text_zstd)
|
"SELECT f.path, f.content_state, ff.reason, LENGTH(d.text_zstd)
|
||||||
FROM files f
|
FROM files f
|
||||||
LEFT JOIN documents_text d ON d.file_id = f.id
|
LEFT JOIN documents_text d ON d.file_id = f.id
|
||||||
LEFT JOIN failed_files ff ON ff.file_id = f.id
|
LEFT JOIN failed_files ff ON ff.file_id = f.id
|
||||||
ORDER BY f.parent, f.name",
|
ORDER BY f.path",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let out = stmt
|
let out = stmt
|
||||||
|
|
@ -778,7 +778,7 @@ fn stored_text(db: &Path, suffix: &str) -> Option<String> {
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT d.text_zstd FROM documents_text d
|
"SELECT d.text_zstd FROM documents_text d
|
||||||
JOIN files f ON f.id = d.file_id
|
JOIN files f ON f.id = d.file_id
|
||||||
WHERE f.parent || f.name LIKE '%' || ?1",
|
WHERE f.path LIKE '%' || ?1",
|
||||||
[suffix],
|
[suffix],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
|
|
@ -900,7 +900,7 @@ fn undecodable_small_files_are_reported_as_failures_not_silently_skipped() {
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT f.content_state, ff.reason FROM files f \
|
"SELECT f.content_state, ff.reason FROM files f \
|
||||||
LEFT JOIN failed_files ff ON ff.file_id = f.id \
|
LEFT JOIN failed_files ff ON ff.file_id = f.id \
|
||||||
WHERE f.parent || f.name LIKE '%bad.txt'",
|
WHERE f.path LIKE '%bad.txt'",
|
||||||
[],
|
[],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
|
|
@ -943,7 +943,7 @@ fn an_unreadable_legacy_office_file_fails_with_a_reason() {
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT f.content_state, ff.reason FROM files f \
|
"SELECT f.content_state, ff.reason FROM files f \
|
||||||
LEFT JOIN failed_files ff ON ff.file_id = f.id \
|
LEFT JOIN failed_files ff ON ff.file_id = f.id \
|
||||||
WHERE f.parent || f.name LIKE '%broken.doc'",
|
WHERE f.path LIKE '%broken.doc'",
|
||||||
[],
|
[],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
|
|
@ -981,7 +981,7 @@ fn extensionless_text_files_are_indexed() {
|
||||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||||
let state_of = |name: &str| -> i64 {
|
let state_of = |name: &str| -> i64 {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT content_state FROM files WHERE parent || name LIKE '%' || ?1",
|
"SELECT content_state FROM files WHERE path LIKE '%' || ?1",
|
||||||
[name],
|
[name],
|
||||||
|r| r.get(0),
|
|r| r.get(0),
|
||||||
)
|
)
|
||||||
|
|
@ -1162,7 +1162,7 @@ fn an_empty_file_is_done_with_no_snippet_sidecar() {
|
||||||
let (state, sidecars): (i64, i64) = conn
|
let (state, sidecars): (i64, i64) = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT f.content_state, (SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id)
|
"SELECT f.content_state, (SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id)
|
||||||
FROM files f WHERE f.parent || f.name LIKE '%empty.txt'",
|
FROM files f WHERE f.path LIKE '%empty.txt'",
|
||||||
[],
|
[],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||||
)
|
)
|
||||||
|
|
@ -1188,7 +1188,7 @@ fn the_content_extension_filter_still_excludes_small_text_files() {
|
||||||
|
|
||||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||||
let states: Vec<(String, i64)> = conn
|
let states: Vec<(String, i64)> = conn
|
||||||
.prepare("SELECT parent || name, content_state FROM files ORDER BY parent, name")
|
.prepare("SELECT path, content_state FROM files ORDER BY path")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|
@ -1824,28 +1824,7 @@ fn the_wal_stays_bounded_during_a_run() {
|
||||||
/// A run cut short is exactly when the log is at its largest and nothing else
|
/// A run cut short is exactly when the log is at its largest and nothing else
|
||||||
/// will come along to land it: the writer connection closes, and the next run
|
/// will come along to land it: the writer connection closes, and the next run
|
||||||
/// may be hours away. So Stop ends the *indexing*, and the pass that follows
|
/// may be hours away. So Stop ends the *indexing*, and the pass that follows
|
||||||
/// runs either way.
|
/// runs either way — visible as `Optimizing` until it is done.
|
||||||
///
|
|
||||||
/// # Why this asks SQLite rather than watching the status
|
|
||||||
///
|
|
||||||
/// This used to poll `get_status()` at 1 ms hoping to catch
|
|
||||||
/// `IndexingStatus::Optimizing` as it went past, and assert it had seen it.
|
|
||||||
/// That is a sample of a transient state, and it fails for reasons that have
|
|
||||||
/// nothing to do with the behaviour under test: a loaded machine deschedules
|
|
||||||
/// the polling thread, and — worse — *making the indexer faster shortens the
|
|
||||||
/// window*, so the test failed more often the better the code got. It was
|
|
||||||
/// failing roughly a third of the time before any of that, and five times in
|
|
||||||
/// six after a round of writer optimisations.
|
|
||||||
///
|
|
||||||
/// So it asks the question directly instead: was `PRAGMA optimize` handed to
|
|
||||||
/// SQLite for this index? [`repo::optimize_count`] is a latch rather than a
|
|
||||||
/// sample, so no amount of speed or scheduling can hide the answer. Per the
|
|
||||||
/// pass's own contract, the pragma reaching SQLite is the optimization
|
|
||||||
/// happening — what SQLite then decides to re-analyse is its business, and
|
|
||||||
/// deliberately nothing when no table has drifted.
|
|
||||||
///
|
|
||||||
/// The log assertion stays, because it checks the other half of the pass: the
|
|
||||||
/// trailing checkpoint that a stopped run exists to get.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_stopped_run_is_still_optimized() {
|
fn a_stopped_run_is_still_optimized() {
|
||||||
let root = tmp_dir("stop-optimize");
|
let root = tmp_dir("stop-optimize");
|
||||||
|
|
@ -1859,11 +1838,6 @@ fn a_stopped_run_is_still_optimized() {
|
||||||
let db_dir = tmp_dir("stop-optimize-db");
|
let db_dir = tmp_dir("stop-optimize-db");
|
||||||
let db = db_dir.join("index.sqlite");
|
let db = db_dir.join("index.sqlite");
|
||||||
let wal = db_dir.join("index.sqlite-wal");
|
let wal = db_dir.join("index.sqlite-wal");
|
||||||
let dir_key = db_dir.to_string_lossy().into_owned();
|
|
||||||
|
|
||||||
// Per-directory, so a sibling test optimizing its own scratch index on
|
|
||||||
// another thread cannot satisfy this.
|
|
||||||
let before = quicksearch_core::db::repo::optimize_count(&dir_key);
|
|
||||||
|
|
||||||
let service = IndexingService::new();
|
let service = IndexingService::new();
|
||||||
service
|
service
|
||||||
|
|
@ -1887,11 +1861,11 @@ fn a_stopped_run_is_still_optimized() {
|
||||||
}
|
}
|
||||||
service.request_stop();
|
service.request_stop();
|
||||||
|
|
||||||
// Idle is the *end* of the pass, and unlike Optimizing it is a resting
|
let mut saw_optimizing = false;
|
||||||
// state — waiting for it cannot miss it however fast the pass was.
|
|
||||||
let idle_by = Instant::now() + Duration::from_secs(120);
|
let idle_by = Instant::now() + Duration::from_secs(120);
|
||||||
loop {
|
loop {
|
||||||
match service.get_status() {
|
match service.get_status() {
|
||||||
|
IndexingStatus::Optimizing => saw_optimizing = true,
|
||||||
IndexingStatus::Idle => break,
|
IndexingStatus::Idle => break,
|
||||||
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
|
IndexingStatus::Error(e) => panic!("indexing failed: {}", e),
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|
@ -1903,10 +1877,9 @@ fn a_stopped_run_is_still_optimized() {
|
||||||
std::thread::sleep(Duration::from_millis(1));
|
std::thread::sleep(Duration::from_millis(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
assert_eq!(
|
assert!(
|
||||||
quicksearch_core::db::repo::optimize_count(&dir_key),
|
saw_optimizing,
|
||||||
before + 1,
|
"a stopped run must still publish Optimizing"
|
||||||
"a stopped run must still run PRAGMA optimize against its index"
|
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
std::fs::metadata(&wal).map(|m| m.len()).unwrap_or(0),
|
std::fs::metadata(&wal).map(|m| m.len()).unwrap_or(0),
|
||||||
|
|
@ -1960,7 +1933,7 @@ fn high_byte_binaries_are_listed_but_not_text_extracted() {
|
||||||
"SELECT f.content_state,
|
"SELECT f.content_state,
|
||||||
(SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id),
|
(SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id),
|
||||||
(SELECT COUNT(*) FROM failed_files x WHERE x.file_id = f.id)
|
(SELECT COUNT(*) FROM failed_files x WHERE x.file_id = f.id)
|
||||||
FROM files f WHERE f.parent || f.name LIKE '%' || ?1",
|
FROM files f WHERE f.path LIKE '%' || ?1",
|
||||||
[suffix],
|
[suffix],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,779 +0,0 @@
|
||||||
//! Fuzzing the cascade's prefilters.
|
|
||||||
//!
|
|
||||||
//! Two passes narrow what they scan by "at least one of these literals must be
|
|
||||||
//! present" — the fuzzy full-text pass by its pigeonhole chunks, the `regex:`
|
|
||||||
//! passes by the literals extracted from the pattern. Both go through
|
|
||||||
//! [`quicksearch_core::search::prefilter`], and both share a failure mode that
|
|
||||||
//! is **silent**: a lost hit looks exactly like a file that does not match, and
|
|
||||||
//! no user could tell the difference. So neither is defended by a handful of
|
|
||||||
//! examples.
|
|
||||||
//!
|
|
||||||
//! The regex half gets a stronger check than the fuzzy half, because it has a
|
|
||||||
//! perfect oracle: running the compiled regex over each body *is* the right
|
|
||||||
//! answer, so the assertion is set equality rather than one-sided recall.
|
|
||||||
//!
|
|
||||||
//! # The generator
|
|
||||||
//!
|
|
||||||
//! Rather than invent queries and assert an expected answer, this manufactures
|
|
||||||
//! queries whose correct answer is known **by construction**:
|
|
||||||
//!
|
|
||||||
//! 1. take a document from the corpus,
|
|
||||||
//! 2. cut a substring of it — text the document provably contains,
|
|
||||||
//! 3. corrupt that substring `N ≤ k` times,
|
|
||||||
//! 4. search for the result. The document it came from **must** be found.
|
|
||||||
//!
|
|
||||||
//! Step 4 needs no oracle and no reference implementation: the term is within
|
|
||||||
//! `N` edits of something the document really contains, so a ≤`k`-edit
|
|
||||||
//! alignment exists and the prefilter is not allowed to exclude it.
|
|
||||||
//!
|
|
||||||
//! # Substring length is swept, not random
|
|
||||||
//!
|
|
||||||
//! Documents are long, so drawing the length uniformly would spend nearly every
|
|
||||||
//! iteration on long terms — the case least likely to be broken. Instead
|
|
||||||
//! [`ITERS_PER_LEN`] iterations run at *every* length from 1 to [`MAX_SWEEP`],
|
|
||||||
//! then a sparse tail for long terms. Short lengths are where the chunking, the
|
|
||||||
//! floors and the boundary conditions all live, and at the default cap the
|
|
||||||
//! `1..=20` sweep straddles every one of them: the 3-character fuzzy floor, and
|
|
||||||
//! `3 × (cap + 1) = 9` where the prefilter becomes legal.
|
|
||||||
//!
|
|
||||||
//! The bottom of the sweep is asserted rather than skipped. Below the fuzzy
|
|
||||||
//! floor no `Bitap` is built and the pass must not scan at all.
|
|
||||||
//!
|
|
||||||
//! # Edits are counted in bytes
|
|
||||||
//!
|
|
||||||
//! Bitap's budget is a byte budget, so substituting `é` (two bytes) for `x`
|
|
||||||
//! (one) is a distance of two, not one. Corruption works on characters to keep
|
|
||||||
//! the term valid UTF-8, then the true byte distance is measured against the
|
|
||||||
//! original substring and recall is only asserted when it really is within
|
|
||||||
//! budget. Without that, a non-ASCII corpus would quietly start generating
|
|
||||||
//! out-of-budget queries and every "miss" would be correct behaviour — the
|
|
||||||
//! test would pass by not testing anything.
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cargo test --release -p quicksearch-core --test fuzzy_prefilter_fuzz
|
|
||||||
//! QSB_FUZZ_ITERS=2000 cargo test --release -p quicksearch-core \
|
|
||||||
//! --test fuzzy_prefilter_fuzz -- --nocapture
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
|
|
||||||
use quicksearch_core::query::split::split_for_cascade;
|
|
||||||
use quicksearch_core::search::fuzzy::{edit_budget, pigeonhole_chunks, Bitap, TRIGRAM_FLOOR};
|
|
||||||
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
use common::{scratch_db, Lcg};
|
|
||||||
|
|
||||||
/// Iterations at each swept substring length.
|
|
||||||
const ITERS_PER_LEN: usize = 100;
|
|
||||||
|
|
||||||
/// Top of the dense sweep. Every length from 1 to here gets [`ITERS_PER_LEN`]
|
|
||||||
/// iterations.
|
|
||||||
const MAX_SWEEP: usize = 20;
|
|
||||||
|
|
||||||
/// Long terms, sampled rather than swept. 64 is `Bitap`'s pattern ceiling and
|
|
||||||
/// 80 is past it, where the fuzzy passes must decline to run at all.
|
|
||||||
const LONG_TAIL: [usize; 6] = [24, 32, 48, 64, 80, 200];
|
|
||||||
|
|
||||||
/// Edit-distance caps to sweep beneath the length sweep. Each moves both the
|
|
||||||
/// budget and the `3 × (cap + 1)` guard.
|
|
||||||
const CAPS: [usize; 5] = [0, 1, 2, 3, 4];
|
|
||||||
|
|
||||||
/// How many caps the ungated run sweeps. The soak takes all of [`CAPS`].
|
|
||||||
const BOUNDED_CAPS: usize = 3;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Corpus
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Documents to seed. Small — this is a correctness harness, and every
|
|
||||||
/// iteration runs a whole cascade — but varied enough that a query drawn from
|
|
||||||
/// one document meets plenty of others it must reject.
|
|
||||||
const DOCS: usize = 60;
|
|
||||||
|
|
||||||
/// Bodies the generator draws substrings from.
|
|
||||||
///
|
|
||||||
/// Deliberately hostile, because the substring generator carries whatever is in
|
|
||||||
/// the corpus straight into the term and then into the FTS chunks:
|
|
||||||
///
|
|
||||||
/// * **FTS5 metacharacters** — `"`, `*`, `:`, `^`, `-`, `(`, `)` and the bare
|
|
||||||
/// word `NEAR`. A chunk containing these must be quoted into inertness by
|
|
||||||
/// `translator::quote_phrase`, or the query is a syntax error rather than a
|
|
||||||
/// search. This is the likeliest way to break the feature and it needs no
|
|
||||||
/// special case in the generator: it is simply in the text.
|
|
||||||
/// * **Multi-byte characters** at 2, 3 and 4 bytes, so a byte-wise chunk split
|
|
||||||
/// would produce invalid UTF-8.
|
|
||||||
/// * **Diacritics**, which the trigram index folds (`remove_diacritics 1`) and
|
|
||||||
/// bitap does not — the prefilter must stay a superset across that.
|
|
||||||
/// * **Mixed case**, which also exercises the case-insensitive mask table.
|
|
||||||
/// * **Degenerate shapes** — a repeated character, where every chunk of a
|
|
||||||
/// substring is identical and the substring is self-overlapping.
|
|
||||||
fn bodies() -> Vec<String> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut lcg = Lcg::new(0xf0072);
|
|
||||||
let words = [
|
|
||||||
"quartzite",
|
|
||||||
"Report",
|
|
||||||
"SUMMARY",
|
|
||||||
"café",
|
|
||||||
"naïve",
|
|
||||||
"Ünicode",
|
|
||||||
"日本語テキスト",
|
|
||||||
"emoji🙂here",
|
|
||||||
"NEAR",
|
|
||||||
"wild*card",
|
|
||||||
"colon:sep",
|
|
||||||
"quote\"mark",
|
|
||||||
"paren(then)",
|
|
||||||
"dash-joined",
|
|
||||||
"caret^up",
|
|
||||||
"aaaaaaaaaa",
|
|
||||||
"mixedCaseWord",
|
|
||||||
"budget",
|
|
||||||
"revenue",
|
|
||||||
"planning",
|
|
||||||
];
|
|
||||||
for d in 0..DOCS {
|
|
||||||
let n = 20 + (lcg.next() as usize % 40);
|
|
||||||
let mut body = String::new();
|
|
||||||
for _ in 0..n {
|
|
||||||
body.push_str(words[lcg.next() as usize % words.len()]);
|
|
||||||
body.push(' ');
|
|
||||||
}
|
|
||||||
// One document that is nothing but a repeated character, and one that
|
|
||||||
// is empty, as fixed shapes beside the random ones.
|
|
||||||
match d {
|
|
||||||
0 => body = "b".repeat(400),
|
|
||||||
1 => body = String::new(),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
out.push(body);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Oracles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Minimum Levenshtein distance between `pattern` and any substring of `hay`,
|
|
||||||
/// over **bytes**, ASCII-case-insensitively — the metric the matcher uses.
|
|
||||||
///
|
|
||||||
/// The shared brute-force reference. It is what decides whether a corrupted
|
|
||||||
/// term is genuinely inside the edit budget, and what the three-way comparison
|
|
||||||
/// checks both cascade paths against.
|
|
||||||
fn oracle_distance(pattern: &[u8], hay: &[u8]) -> usize {
|
|
||||||
if hay.is_empty() || pattern.is_empty() {
|
|
||||||
return usize::MAX;
|
|
||||||
}
|
|
||||||
let m = pattern.len();
|
|
||||||
let mut prev: Vec<usize> = vec![0; hay.len() + 1];
|
|
||||||
let mut cur = vec![0; hay.len() + 1];
|
|
||||||
for i in 1..=m {
|
|
||||||
cur[0] = i;
|
|
||||||
for j in 1..=hay.len() {
|
|
||||||
let cost = usize::from(!pattern[i - 1].eq_ignore_ascii_case(&hay[j - 1]));
|
|
||||||
cur[j] = (prev[j - 1] + cost).min(prev[j] + 1).min(cur[j - 1] + 1);
|
|
||||||
}
|
|
||||||
std::mem::swap(&mut prev, &mut cur);
|
|
||||||
}
|
|
||||||
prev.iter().copied().min().unwrap_or(usize::MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The generator
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// One corruption applied to a character sequence.
|
|
||||||
fn corrupt(chars: &mut Vec<char>, lcg: &mut Lcg) {
|
|
||||||
// A small alphabet of replacements, including multi-byte ones so that a
|
|
||||||
// single character edit can be a multi-byte edit.
|
|
||||||
const REPLACEMENTS: [char; 6] = ['x', 'Q', '7', 'é', '語', '🙂'];
|
|
||||||
let pick = REPLACEMENTS[lcg.next() as usize % REPLACEMENTS.len()];
|
|
||||||
if chars.is_empty() {
|
|
||||||
chars.push(pick);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let at = lcg.next() as usize % chars.len();
|
|
||||||
match lcg.next() % 3 {
|
|
||||||
0 => chars[at] = pick, // substitution
|
|
||||||
1 => chars.insert(at, pick), // insertion
|
|
||||||
_ => {
|
|
||||||
chars.remove(at); // deletion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cut `len` characters out of `body` at a random offset, or `None` when the
|
|
||||||
/// body is too short to give that many.
|
|
||||||
fn substring_of<'a>(body: &'a str, len: usize, lcg: &mut Lcg) -> Option<&'a str> {
|
|
||||||
let bounds: Vec<usize> = body
|
|
||||||
.char_indices()
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.chain(std::iter::once(body.len()))
|
|
||||||
.collect();
|
|
||||||
let chars = bounds.len() - 1;
|
|
||||||
if chars < len || len == 0 {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let start = lcg.next() as usize % (chars - len + 1);
|
|
||||||
Some(&body[bounds[start]..bounds[start + len]])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 1: the pigeonhole invariant, with no database in sight
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// The argument the whole prefilter rests on, isolated from FTS, from SQLite
|
|
||||||
/// and from bitap: if the term occurs within `k` edits, some chunk of the
|
|
||||||
/// `k + 1`-way split occurs **verbatim**.
|
|
||||||
///
|
|
||||||
/// Driven by the same corrupt-a-known-substring generator, because a randomly
|
|
||||||
/// drawn (term, text) pair almost never has a ≤`k`-edit alignment and the
|
|
||||||
/// property would be vacuous.
|
|
||||||
#[test]
|
|
||||||
fn a_surviving_chunk_always_remains_after_k_edits() {
|
|
||||||
let bodies = bodies();
|
|
||||||
let mut lcg = Lcg::new(0xc0ffee);
|
|
||||||
let mut exercised = 0usize;
|
|
||||||
|
|
||||||
for &cap in &CAPS {
|
|
||||||
for len in sweep_lengths() {
|
|
||||||
for _ in 0..ITERS_PER_LEN {
|
|
||||||
let body = &bodies[lcg.next() as usize % bodies.len()];
|
|
||||||
let Some(original) = substring_of(body, len, &mut lcg) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(k) = edit_budget(original.len(), cap) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut chars: Vec<char> = original.chars().collect();
|
|
||||||
let edits = lcg.next() as usize % (k + 1);
|
|
||||||
for _ in 0..edits {
|
|
||||||
corrupt(&mut chars, &mut lcg);
|
|
||||||
}
|
|
||||||
let term: String = chars.into_iter().collect();
|
|
||||||
|
|
||||||
// Only meaningful when the corrupted term really is within the
|
|
||||||
// budget of the text — see the module note on byte distance.
|
|
||||||
let distance = oracle_distance(term.as_bytes(), body.as_bytes());
|
|
||||||
let Some(chunks) = pigeonhole_chunks(&term, k) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if distance > k {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The partition itself, checked every time: chunks that
|
|
||||||
// overlapped or skipped text would break the argument silently.
|
|
||||||
assert_eq!(chunks.concat(), term, "chunks must partition the term");
|
|
||||||
assert_eq!(chunks.len(), k + 1, "one chunk per edit, plus one");
|
|
||||||
assert!(
|
|
||||||
chunks.iter().all(|c| c.chars().count() >= TRIGRAM_FLOOR),
|
|
||||||
"every chunk must reach the trigram floor: {:?}",
|
|
||||||
chunks
|
|
||||||
);
|
|
||||||
|
|
||||||
// The property.
|
|
||||||
let folded_body = body.to_lowercase();
|
|
||||||
assert!(
|
|
||||||
chunks
|
|
||||||
.iter()
|
|
||||||
.any(|c| folded_body.contains(&c.to_lowercase())),
|
|
||||||
"no chunk of {:?} survived in the body it came from \
|
|
||||||
(k={}, distance={}, chunks={:?})",
|
|
||||||
term,
|
|
||||||
k,
|
|
||||||
distance,
|
|
||||||
chunks
|
|
||||||
);
|
|
||||||
exercised += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
exercised > 500,
|
|
||||||
"the generator produced only {} in-budget cases; it is not exercising \
|
|
||||||
the property",
|
|
||||||
exercised
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adversarial placement, which random corruption will almost never construct:
|
|
||||||
/// damage exactly `k` of the `k + 1` chunks, every combination, and check the
|
|
||||||
/// untouched one is still there to be found.
|
|
||||||
#[test]
|
|
||||||
fn damaging_every_chunk_but_one_leaves_that_one_intact() {
|
|
||||||
for k in 0..=3usize {
|
|
||||||
let term: String = (0..(k + 1) * 4)
|
|
||||||
.map(|i| char::from(b'a' + (i % 26) as u8))
|
|
||||||
.collect();
|
|
||||||
let chunks = pigeonhole_chunks(&term, k).expect("long enough by construction");
|
|
||||||
// Damage all but one chunk, for each choice of the survivor.
|
|
||||||
for spared in 0..chunks.len() {
|
|
||||||
let mut text = String::new();
|
|
||||||
for (i, chunk) in chunks.iter().enumerate() {
|
|
||||||
if i == spared {
|
|
||||||
text.push_str(chunk);
|
|
||||||
} else {
|
|
||||||
// One substitution inside this chunk.
|
|
||||||
let mut c: Vec<char> = chunk.chars().collect();
|
|
||||||
c[0] = 'Z';
|
|
||||||
text.extend(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
chunks.iter().any(|c| text.contains(*c)),
|
|
||||||
"k={} spared={} term={:?} text={:?}",
|
|
||||||
k,
|
|
||||||
spared,
|
|
||||||
term,
|
|
||||||
text
|
|
||||||
);
|
|
||||||
// And the damaged text really is within budget, so this is a case
|
|
||||||
// the pass would have to find.
|
|
||||||
assert!(
|
|
||||||
oracle_distance(term.as_bytes(), text.as_bytes()) <= k,
|
|
||||||
"the constructed text should be within {} edits",
|
|
||||||
k
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Property 2: end-to-end recall, three ways
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Lengths the sweep visits: dense from 1, then a sparse tail.
|
|
||||||
fn sweep_lengths() -> Vec<usize> {
|
|
||||||
(1..=MAX_SWEEP).chain(LONG_TAIL).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn iters_per_len() -> usize {
|
|
||||||
std::env::var("QSB_FUZZ_ITERS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(ITERS_PER_LEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn caps() -> &'static [usize] {
|
|
||||||
if std::env::var("QSB_FUZZ_ITERS").is_ok() {
|
|
||||||
&CAPS
|
|
||||||
} else {
|
|
||||||
&CAPS[..BOUNDED_CAPS]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run one fuzzy search and collect the file ids it found.
|
|
||||||
fn search_ids(conn: &rusqlite::Connection, term: &str, cap: usize) -> Vec<i64> {
|
|
||||||
let split = split_for_cascade(term).expect("any term parses; it degrades rather than errors");
|
|
||||||
let latest = AtomicU64::new(1);
|
|
||||||
let options = SearchOptions {
|
|
||||||
fuzzy: true,
|
|
||||||
fuzzy_max_edits: cap,
|
|
||||||
limit: 100_000,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let mut ids = Vec::new();
|
|
||||||
let mut sink = |hits: Vec<SearchHit>| ids.extend(hits.iter().map(|h| h.file_id));
|
|
||||||
cascade::run(conn, &split, &options, 1, &latest, &mut sink).expect("the cascade runs");
|
|
||||||
ids.sort();
|
|
||||||
ids.dedup();
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The headline property: a term cut from a document and corrupted within the
|
|
||||||
/// edit budget still finds that document.
|
|
||||||
#[test]
|
|
||||||
fn a_corrupted_substring_still_finds_the_document_it_came_from() {
|
|
||||||
let bodies = bodies();
|
|
||||||
let db = scratch_db("fuzzprefilter");
|
|
||||||
let ids = seed(&db, &bodies);
|
|
||||||
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
|
|
||||||
.expect("open the seeded index");
|
|
||||||
|
|
||||||
let mut lcg = Lcg::new(0x5ca1ab1e);
|
|
||||||
let mut checked = 0usize;
|
|
||||||
let mut below_floor = 0usize;
|
|
||||||
|
|
||||||
for &cap in caps() {
|
|
||||||
for len in sweep_lengths() {
|
|
||||||
for _ in 0..iters_per_len() {
|
|
||||||
let doc = lcg.next() as usize % bodies.len();
|
|
||||||
let body = &bodies[doc];
|
|
||||||
let Some(original) = substring_of(body, len, &mut lcg) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut chars: Vec<char> = original.chars().collect();
|
|
||||||
let planned = edit_budget(original.len(), cap).unwrap_or(0);
|
|
||||||
let edits = if planned == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
lcg.next() as usize % (planned + 1)
|
|
||||||
};
|
|
||||||
for _ in 0..edits {
|
|
||||||
corrupt(&mut chars, &mut lcg);
|
|
||||||
}
|
|
||||||
let term: String = chars.into_iter().collect();
|
|
||||||
if term.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// **The cascade does not search the string typed at it.** The
|
|
||||||
// query goes through the lexer first, and a term cut out of
|
|
||||||
// real document text is full of things the lexer acts on:
|
|
||||||
// whitespace is stripped and re-joined, `*` becomes a wildcard,
|
|
||||||
// `key:value` may become a filter, `"` opens a phrase. The
|
|
||||||
// first thing this harness found was a three-character term
|
|
||||||
// `" Qi"` whose leading space the lexer drops, leaving two
|
|
||||||
// characters — below the fuzzy floor, so no matcher is built
|
|
||||||
// and no document can be found. That is correct behaviour, and
|
|
||||||
// asserting against the raw string called it a lost hit.
|
|
||||||
//
|
|
||||||
// So every decision below is made against the *parsed* term,
|
|
||||||
// exactly as the passes make it.
|
|
||||||
let Ok(split) = split_for_cascade(&term) else {
|
|
||||||
// A term that parses to an error (a `regex:` fragment with
|
|
||||||
// bad syntax, a filter key with an unusable value) is not a
|
|
||||||
// fuzzy search at all.
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if split.pattern.is_wildcard() || split.regex.is_some() {
|
|
||||||
// "Bitap is a literal matcher; wildcard terms don't fuzz"
|
|
||||||
// — the fuzzy passes decline outright, so there is no
|
|
||||||
// recall to demand.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let effective = split.term.as_str();
|
|
||||||
|
|
||||||
let Some(k) = edit_budget(effective.len(), cap) else {
|
|
||||||
// Below the fuzzy floor (or past Bitap's 64-byte ceiling):
|
|
||||||
// the pass must not run, so there is nothing to demand of
|
|
||||||
// it beyond not crashing.
|
|
||||||
below_floor += 1;
|
|
||||||
let _ = search_ids(&conn, &term, cap);
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if Bitap::new(effective.as_bytes(), k).is_none() {
|
|
||||||
let _ = search_ids(&conn, &term, cap);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The budget is over bytes; only assert recall when the term
|
|
||||||
// really is within it.
|
|
||||||
if oracle_distance(effective.as_bytes(), body.as_bytes()) > k {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let found = search_ids(&conn, &term, cap);
|
|
||||||
assert!(
|
|
||||||
found.contains(&ids[doc]),
|
|
||||||
"lost the document the term came from\n typed {:?}\n \
|
|
||||||
parsed {:?}\n original {:?}\n cap {} k {} edits {}\n \
|
|
||||||
chunks {:?}\n body {:?}",
|
|
||||||
term,
|
|
||||||
effective,
|
|
||||||
original,
|
|
||||||
cap,
|
|
||||||
k,
|
|
||||||
edits,
|
|
||||||
pigeonhole_chunks(effective, k),
|
|
||||||
&body.chars().take(120).collect::<String>(),
|
|
||||||
);
|
|
||||||
checked += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"fuzzy prefilter fuzz: {} recall assertions, {} terms below the fuzzy floor",
|
|
||||||
checked, below_floor
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
checked > 200,
|
|
||||||
"only {} recall assertions ran; the generator is not producing \
|
|
||||||
in-budget terms",
|
|
||||||
checked
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&db).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The two-sided property, against a brute-force oracle over the **whole**
|
|
||||||
/// corpus rather than just the document the term came from.
|
|
||||||
///
|
|
||||||
/// Recall alone says the prefilter did not lose the one hit we planted; it says
|
|
||||||
/// nothing about the other fifty-nine documents. This checks both directions:
|
|
||||||
///
|
|
||||||
/// * **No lost hits** — every document within `k` edits of the term appears in
|
|
||||||
/// the results, at whatever stage the cascade files it under. This is the
|
|
||||||
/// direction the prefilter can break, and the one with no visible symptom.
|
|
||||||
/// * **No invented hits** — every stage-8 (fuzzy content) hit really is within
|
|
||||||
/// `k` edits. The prefilter is only a superset, so it must not admit anything
|
|
||||||
/// the verification below it should have rejected.
|
|
||||||
///
|
|
||||||
/// A true oracle, not a comparison against the pre-prefilter code path: a bug
|
|
||||||
/// living in `Bitap` itself would be present in both cascade paths and a
|
|
||||||
/// differential between them would agree, happily, on the wrong answer.
|
|
||||||
///
|
|
||||||
/// Far fewer iterations than the recall sweep, because it runs a Levenshtein DP
|
|
||||||
/// over every document per iteration.
|
|
||||||
#[test]
|
|
||||||
fn every_document_within_the_budget_is_found_and_nothing_outside_it_is() {
|
|
||||||
const ITERS: usize = 5;
|
|
||||||
|
|
||||||
let bodies = bodies();
|
|
||||||
let db = scratch_db("fuzzprefilter-oracle");
|
|
||||||
let ids = seed(&db, &bodies);
|
|
||||||
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
|
|
||||||
.expect("open the seeded index");
|
|
||||||
|
|
||||||
let mut lcg = Lcg::new(0xd1ce);
|
|
||||||
let mut compared = 0usize;
|
|
||||||
|
|
||||||
for &cap in caps() {
|
|
||||||
for len in sweep_lengths() {
|
|
||||||
for _ in 0..ITERS {
|
|
||||||
let doc = lcg.next() as usize % bodies.len();
|
|
||||||
let Some(original) = substring_of(&bodies[doc], len, &mut lcg) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let mut chars: Vec<char> = original.chars().collect();
|
|
||||||
let planned = edit_budget(original.len(), cap).unwrap_or(0);
|
|
||||||
let edits = if planned == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
lcg.next() as usize % (planned + 1)
|
|
||||||
};
|
|
||||||
for _ in 0..edits {
|
|
||||||
corrupt(&mut chars, &mut lcg);
|
|
||||||
}
|
|
||||||
let term: String = chars.into_iter().collect();
|
|
||||||
if term.trim().is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(split) = split_for_cascade(&term) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if split.pattern.is_wildcard() || split.regex.is_some() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let effective = split.term.as_str();
|
|
||||||
let Some(k) = edit_budget(effective.len(), cap) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if Bitap::new(effective.as_bytes(), k).is_none() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let found = search_ids(&conn, &term, cap);
|
|
||||||
for (i, body) in bodies.iter().enumerate() {
|
|
||||||
let within = oracle_distance(effective.as_bytes(), body.as_bytes()) <= k;
|
|
||||||
if within {
|
|
||||||
assert!(
|
|
||||||
found.contains(&ids[i]),
|
|
||||||
"lost a hit the oracle says is within {} edits\n \
|
|
||||||
parsed {:?}\n doc {} {:?}\n chunks {:?}",
|
|
||||||
k,
|
|
||||||
effective,
|
|
||||||
i,
|
|
||||||
&body.chars().take(80).collect::<String>(),
|
|
||||||
pigeonhole_chunks(effective, k),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compared += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("prefilter oracle: {} queries compared corpus-wide", compared);
|
|
||||||
assert!(compared > 50, "only {} queries compared", compared);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&db).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The regex prefilter
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Rewrite `sub` into a regex that still matches it.
|
|
||||||
///
|
|
||||||
/// The same trick as the fuzzy generator, in the other direction: rather than
|
|
||||||
/// corrupt the text and rely on an edit budget, this loosens the *pattern* in
|
|
||||||
/// ways that provably preserve the match, so the source document is once again
|
|
||||||
/// a known-correct answer. Each transformation keeps `sub` in the language:
|
|
||||||
/// `.` matches any one character (the corpus has no newlines), a class
|
|
||||||
/// containing the character matches it, an alternation offering it matches it,
|
|
||||||
/// `?` and `+` both admit exactly one occurrence.
|
|
||||||
///
|
|
||||||
/// The point is to reach patterns whose required-literal set is interesting:
|
|
||||||
/// a class or an alternation splits one literal into several, a `?` splits the
|
|
||||||
/// set into "with" and "without", and a leading `.*` destroys the prefix set
|
|
||||||
/// entirely so only the suffix set can save it.
|
|
||||||
fn regexify(sub: &str, lcg: &mut Lcg) -> String {
|
|
||||||
let esc = |c: char| regex::escape(&c.to_string());
|
|
||||||
let mut out = String::new();
|
|
||||||
for c in sub.chars() {
|
|
||||||
// Most characters stay literal, or the pattern stops resembling
|
|
||||||
// anything a person would type and every literal set goes empty.
|
|
||||||
match lcg.next() % 10 {
|
|
||||||
0 if c != '\n' => out.push('.'),
|
|
||||||
1 => out.push_str(&format!("[{}z]", esc(c))),
|
|
||||||
2 => out.push_str(&format!("(?:{}|zzq)", esc(c))),
|
|
||||||
3 => out.push_str(&format!("{}?", esc(c))),
|
|
||||||
4 => out.push_str(&format!("{}+", esc(c))),
|
|
||||||
_ => out.push_str(&esc(c)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match lcg.next() % 6 {
|
|
||||||
0 => format!(".*{out}"),
|
|
||||||
1 => format!("{out}.*"),
|
|
||||||
_ => out,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every document the regex matches is found, and nothing else is.
|
|
||||||
///
|
|
||||||
/// Set equality, not recall: the regex passes accept a row when the pattern
|
|
||||||
/// matches its name, its path or its body, and all three are computable here.
|
|
||||||
/// So this pins the prefilter from both sides at once — it may not lose a row,
|
|
||||||
/// and the rows it lets through must still be verified rather than admitted on
|
|
||||||
/// the strength of holding a literal.
|
|
||||||
#[test]
|
|
||||||
fn a_regex_finds_exactly_the_documents_it_matches() {
|
|
||||||
let bodies = bodies();
|
|
||||||
let db = scratch_db("prefilter-regex");
|
|
||||||
let ids = seed(&db, &bodies);
|
|
||||||
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
|
|
||||||
.expect("open the seeded index");
|
|
||||||
|
|
||||||
let mut lcg = Lcg::new(0xb0a7);
|
|
||||||
let mut checked = 0usize;
|
|
||||||
let mut with_prefilter = 0usize;
|
|
||||||
|
|
||||||
for len in sweep_lengths() {
|
|
||||||
for _ in 0..iters_per_len().min(40) {
|
|
||||||
let doc = lcg.next() as usize % bodies.len();
|
|
||||||
let Some(sub) = substring_of(&bodies[doc], len, &mut lcg) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let pattern = regexify(sub, &mut lcg);
|
|
||||||
let query = format!("regex:\"{}\"", pattern);
|
|
||||||
// Anything the query parser does not hand back verbatim is not a
|
|
||||||
// test of the prefilter — a pattern carrying a quote, or one the
|
|
||||||
// lexer splits. Round-tripping is the cheapest way to say so.
|
|
||||||
let Ok(split) = split_for_cascade(&query) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(re) = split.regex.as_ref() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if re.source != pattern {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if re.required().is_some() {
|
|
||||||
with_prefilter += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The oracle: the pass accepts a row when the pattern matches any
|
|
||||||
// of the three fields it looks at.
|
|
||||||
let mut want: Vec<i64> = Vec::new();
|
|
||||||
for (i, body) in bodies.iter().enumerate() {
|
|
||||||
let name = format!("row{:04}.bin", i);
|
|
||||||
let path = format!("/fuzz/{}", name);
|
|
||||||
if re.is_match(body) || re.is_match(&name) || re.is_match(&path) {
|
|
||||||
want.push(ids[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
want.sort();
|
|
||||||
|
|
||||||
let mut got = regex_search_ids(&conn, &query);
|
|
||||||
got.sort();
|
|
||||||
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
want,
|
|
||||||
"\n pattern {:?}\n from {:?}\n literals {:?}",
|
|
||||||
pattern,
|
|
||||||
sub,
|
|
||||||
re.required().map(|r| r.literals().to_vec()),
|
|
||||||
);
|
|
||||||
checked += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
"regex prefilter: {} patterns compared corpus-wide, {} of them prefiltered",
|
|
||||||
checked, with_prefilter
|
|
||||||
);
|
|
||||||
assert!(checked > 100, "only {} patterns compared", checked);
|
|
||||||
assert!(
|
|
||||||
with_prefilter > checked / 4,
|
|
||||||
"only {} of {} patterns produced a prefilter; the generator is not \
|
|
||||||
reaching the path under test",
|
|
||||||
with_prefilter,
|
|
||||||
checked
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(conn);
|
|
||||||
std::fs::remove_file(&db).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run a regex-only search and collect the file ids it found.
|
|
||||||
fn regex_search_ids(conn: &rusqlite::Connection, query: &str) -> Vec<i64> {
|
|
||||||
let split = split_for_cascade(query).expect("checked by the caller");
|
|
||||||
let latest = AtomicU64::new(1);
|
|
||||||
let options = SearchOptions {
|
|
||||||
limit: 100_000,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
let mut ids = Vec::new();
|
|
||||||
let mut sink = |hits: Vec<SearchHit>| ids.extend(hits.iter().map(|h| h.file_id));
|
|
||||||
cascade::run(conn, &split, &options, 1, &latest, &mut sink).expect("the cascade runs");
|
|
||||||
ids.sort();
|
|
||||||
ids.dedup();
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seed one row per body and return the file ids, in the same order.
|
|
||||||
fn seed(path: &std::path::Path, bodies: &[String]) -> Vec<i64> {
|
|
||||||
use quicksearch_core::db::repo::{insert_file, set_content_done, NewFile};
|
|
||||||
use quicksearch_core::mime::FileType;
|
|
||||||
use quicksearch_core::testutil::zstd_of;
|
|
||||||
|
|
||||||
let mut conn =
|
|
||||||
quicksearch_core::db::open_or_recreate(path.to_str().unwrap(), "trigram").unwrap();
|
|
||||||
let tx = conn.transaction().unwrap();
|
|
||||||
let mut ids = Vec::with_capacity(bodies.len());
|
|
||||||
for (i, body) in bodies.iter().enumerate() {
|
|
||||||
let id = insert_file(
|
|
||||||
&tx,
|
|
||||||
&NewFile {
|
|
||||||
// Names deliberately share nothing with the bodies, so a hit
|
|
||||||
// can only come from the full-text pass and never from the
|
|
||||||
// filename tiers.
|
|
||||||
name: &format!("row{:04}.bin", i),
|
|
||||||
parent: "/fuzz/",
|
|
||||||
size: body.len() as u64,
|
|
||||||
mtime: 1_700_000_000,
|
|
||||||
mime: Some("text/plain"),
|
|
||||||
ftype: FileType::TEXT,
|
|
||||||
hash: None,
|
|
||||||
needs_content: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.expect("unique path");
|
|
||||||
set_content_done(&tx, id, body, zstd_of(body).as_deref()).unwrap();
|
|
||||||
ids.push(id);
|
|
||||||
}
|
|
||||||
tx.commit().unwrap();
|
|
||||||
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);").ok();
|
|
||||||
ids
|
|
||||||
}
|
|
||||||
|
|
@ -62,22 +62,14 @@ fn conn(db: &Path) -> rusqlite::Connection {
|
||||||
rusqlite::Connection::open(db).unwrap()
|
rusqlite::Connection::open(db).unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every indexed path, in **path** order.
|
|
||||||
///
|
|
||||||
/// Sorted here rather than in SQL: the index's own order is `(parent, name)`,
|
|
||||||
/// which groups by directory and is not the same sequence — `/r/keep.txt`
|
|
||||||
/// sorts *before* `/r/sub/k2.txt` there and after it by path. These assertions
|
|
||||||
/// are about which rows survived, so they read best in the order a person
|
|
||||||
/// would list them.
|
|
||||||
fn paths(db: &Path) -> Vec<String> {
|
fn paths(db: &Path) -> Vec<String> {
|
||||||
let c = conn(db);
|
let c = conn(db);
|
||||||
let mut stmt = c.prepare("SELECT parent || name FROM files").unwrap();
|
let mut stmt = c.prepare("SELECT path FROM files ORDER BY path").unwrap();
|
||||||
let mut out: Vec<String> = stmt
|
let out = stmt
|
||||||
.query_map([], |r| r.get::<_, String>(0))
|
.query_map([], |r| r.get::<_, String>(0))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map(|r| r.unwrap())
|
.map(|r| r.unwrap())
|
||||||
.collect();
|
.collect();
|
||||||
out.sort();
|
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,587 +0,0 @@
|
||||||
//! What a search moves through the allocator.
|
|
||||||
//!
|
|
||||||
//! [`search_perf`](search_perf.rs) answers "how long does a keystroke take";
|
|
||||||
//! this answers "how much does it allocate to get there", which is a different
|
|
||||||
//! question with a different answer and, until this existed, no way to measure
|
|
||||||
//! it from the repository at all. `db/repo.rs` already carries one finding of
|
|
||||||
//! exactly this kind — that `zstd::decode_all` was "27 of the 30 GiB a fuzzy
|
|
||||||
//! search moved through the allocator" — and that number came from tooling
|
|
||||||
//! nobody could re-run.
|
|
||||||
//!
|
|
||||||
//! # What is counted, and what is not
|
|
||||||
//!
|
|
||||||
//! This installs a `#[global_allocator]` that wraps [`System`] and counts every
|
|
||||||
//! Rust-side allocation. A global allocator is **per binary**, so this affects
|
|
||||||
//! only this test executable — the shipped `quicksearch` binary is untouched,
|
|
||||||
//! which is what makes an always-available accounting harness safe to keep.
|
|
||||||
//!
|
|
||||||
//! It deliberately does **not** see SQLite. The bundled SQLCipher amalgamation
|
|
||||||
//! calls libc `malloc` directly rather than going through Rust's `GlobalAlloc`,
|
|
||||||
//! so its page cache (32 MiB under [`PRAGMAS_SEARCH`]) and its record buffers
|
|
||||||
//! are invisible here. That is a feature: what is left is precisely the
|
|
||||||
//! cascade's own churn — the per-row `String`s, the document folds, the snippet
|
|
||||||
//! buffers — which is the part the code can do something about.
|
|
||||||
//!
|
|
||||||
//! # Reading the numbers
|
|
||||||
//!
|
|
||||||
//! - **allocs** is the count. It is the number to watch for per-row work: a
|
|
||||||
//! pass that scans the whole table and allocates once per row shows up here
|
|
||||||
//! as a figure proportional to the row count, and a fix shows up as a figure
|
|
||||||
//! proportional to the *hit* count.
|
|
||||||
//! - **bytes** is total traffic — allocation churn. High traffic with a low
|
|
||||||
//! peak means buffers being built and dropped in a loop.
|
|
||||||
//! - **peak live** is the high-water mark of outstanding bytes during the
|
|
||||||
//! query, and the only one of the three that speaks to footprint rather than
|
|
||||||
//! to churn.
|
|
||||||
//!
|
|
||||||
//! Timings are printed for orientation only. The counting allocator adds a
|
|
||||||
//! thread-local read-modify-write to every allocation, so this binary is
|
|
||||||
//! **not** where wall-clock is decided; `search_perf` is.
|
|
||||||
//!
|
|
||||||
//! # What it has measured so far
|
|
||||||
//!
|
|
||||||
//! 50,000 rows, 5,000 stored documents of ~2 KB, 32 MiB index. Baseline is the
|
|
||||||
//! cascade as it stood before this round of work; each column after it is
|
|
||||||
//! cumulative.
|
|
||||||
//!
|
|
||||||
//! | case | allocs | → after | bytes | → after | time | → after |
|
|
||||||
//! |---|---:|---:|---:|---:|---:|---:|
|
|
||||||
//! | no match | 14 | 14 | 0.0 | 0.0 | 2.5 ms | 2.5 ms |
|
|
||||||
//! | literal, rare | 226 | 226 | 0.0 | 0.0 | 2.5 ms | 2.6 ms |
|
|
||||||
//! | wildcard, rare | 50,324 | **348** | 1.2 MiB | 0.0 | 11.5 ms | **2.6 ms** |
|
|
||||||
//! | fuzzy, rare | 150,134 | **242** | 4.0 MiB | 0.0 | 62.1 ms | **17.4 ms** |
|
|
||||||
//! | regex, literal | 50,221 | **283** | 1.3 MiB | 0.1 MiB | 22.4 ms | **2.7 ms** |
|
|
||||||
//! | regex, no literal | — | 46 | — | 0.0 | — | 25.0 ms |
|
|
||||||
//! | common (capped) | 4,056 | 4,056 | 0.6 MiB | 0.6 MiB | 1.4 ms | 1.5 ms |
|
|
||||||
//!
|
|
||||||
//! (`content, many` and `regex, no literal` were added after the baseline was
|
|
||||||
//! taken — the first to give the full-text pass real verification work, the
|
|
||||||
//! second to keep the regex prefilter's *limit* as visible as its win. Neither
|
|
||||||
//! has a "before" column. The 25 ms in the last row is not a regression: it is
|
|
||||||
//! what a `regex:` query cost before this work and still costs when the pattern
|
|
||||||
//! offers no literal to filter on, which is the honest shape of the feature.)
|
|
||||||
//!
|
|
||||||
//! What the table is the reason for keeping:
|
|
||||||
//!
|
|
||||||
//! * borrowing the row's name instead of allocating one per scanned row — the
|
|
||||||
//! 50,324 and 150,134 figures were exactly one and three allocations per row;
|
|
||||||
//! * folding inside the bitap mask table rather than folding each haystack,
|
|
||||||
//! which took the fuzzy pass's remaining two-per-row to nothing;
|
|
||||||
//! * a `LIKE` prefilter for multi-segment wildcards, which had been scanning
|
|
||||||
//! with no SQL filter at all — the whole of the wildcard row's time saving;
|
|
||||||
//! * the pigeonhole trigram prefilter on the fuzzy full-text pass;
|
|
||||||
//! * a non-cryptographic hasher for the emitted-id set, worth ~5% of a fuzzy
|
|
||||||
//! search (see `cascade::IdHasher`);
|
|
||||||
//! * required-literal prefilters on both `regex:` passes, which had been
|
|
||||||
//! running the user's pattern over every name, every path and every stored
|
|
||||||
//! document — the same `Required` machinery the fuzzy pass uses, fed by the
|
|
||||||
//! literal analysis the regex engine already does for its own prefilter.
|
|
||||||
//!
|
|
||||||
//! And what it argued *against*, each recorded as a losing arm in
|
|
||||||
//! `benches/search.rs`: a `memchr2` candidate scan for the case-insensitive
|
|
||||||
//! filename probe, and hoisting `memmem::Finder` construction out of the
|
|
||||||
//! full-text pass. Both are obvious-looking and neither is measurable.
|
|
||||||
//!
|
|
||||||
//! Two things this table is worth reading carefully for. **Allocation churn and
|
|
||||||
//! wall-clock are not the same axis**: the first two changes removed 99.8% of
|
|
||||||
//! the allocations and moved the clock barely at all, because the fuzzy pass's
|
|
||||||
//! cost was zstd and bitap rather than malloc. And **the two big wins are both
|
|
||||||
//! the same idea**: work out what must be present for a row to match, and let
|
|
||||||
//! the database reject the rest. That took wildcards from 11.5 ms to 2.6,
|
|
||||||
//! fuzzy from 62 to 17, and `regex:` from 22 to 2.7 — where the per-row
|
|
||||||
//! micro-optimisations, measured honestly, were worth nothing at all.
|
|
||||||
//!
|
|
||||||
//! Take timings on a quiet machine. The allocation columns are deterministic
|
|
||||||
//! and reproduce bit-for-bit under any load; the time column does not, and a
|
|
||||||
//! busy box inflates it by 2-3x across the board.
|
|
||||||
//!
|
|
||||||
//! Gated by `QSB_SEARCH_ALLOC` so the harness doesn't pay the seed cost on
|
|
||||||
//! every `cargo test`. To run it:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! QSB_SEARCH_ALLOC=1 cargo test --release -p quicksearch-core \
|
|
||||||
//! --test search_alloc -- --nocapture
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! [`PRAGMAS_SEARCH`]: quicksearch_core::db::schema::PRAGMAS_SEARCH
|
|
||||||
//! [`System`]: std::alloc::System
|
|
||||||
|
|
||||||
use std::alloc::{GlobalAlloc, Layout, System};
|
|
||||||
use std::cell::Cell;
|
|
||||||
use std::sync::atomic::AtomicU64;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use quicksearch_core::query::split::split_for_cascade;
|
|
||||||
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
|
||||||
|
|
||||||
mod common;
|
|
||||||
use common::{scratch_db, seed_index, SeedSpec};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The counting allocator
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Counters are **per thread**, not global, and that is load-bearing rather
|
|
||||||
// than an optimization.
|
|
||||||
//
|
|
||||||
// libtest runs `#[test]` functions on concurrent threads in one process, and
|
|
||||||
// there is only one allocator. With global counters, another test's harness
|
|
||||||
// setup — libtest allocates an output-capture buffer per test, before the test
|
|
||||||
// body can take any lock of ours — lands inside whatever region is open here
|
|
||||||
// and is charged to it. That is not hypothetical: it is what the self-test
|
|
||||||
// below caught, twice, and no mutex in this file can fix it because the
|
|
||||||
// offending allocation happens before the other test body runs at all.
|
|
||||||
//
|
|
||||||
// Per-thread counting is immune to that, and is also the more precise
|
|
||||||
// question: `cascade::run` is synchronous and does its work on the calling
|
|
||||||
// thread, so "what did this thread allocate" *is* "what did the cascade
|
|
||||||
// allocate".
|
|
||||||
//
|
|
||||||
// `const`-initialized `Cell`s, deliberately. A `thread_local!` with a lazy
|
|
||||||
// initializer would allocate on first touch — from inside the allocator — and
|
|
||||||
// one with a destructor can panic when touched during thread teardown, which
|
|
||||||
// is exactly when the last deallocations happen. `Cell<u64>` has no `Drop`, so
|
|
||||||
// neither hazard exists.
|
|
||||||
// `LIVE` and `PEAK` are **signed**, and that is not fussiness.
|
|
||||||
//
|
|
||||||
// Per-thread accounting is inherently asymmetric: a buffer allocated on one
|
|
||||||
// thread and freed on another decrements a counter that never incremented, so
|
|
||||||
// a thread's live total legitimately goes negative. libtest does exactly this
|
|
||||||
// — a test thread starts life having freed more than it allocated. Held as
|
|
||||||
// `u64` that reads as ~1.8e19, and `PEAK.max(live)` then latches onto it and
|
|
||||||
// never moves again, so every peak in the table would be reported as 8 bytes.
|
|
||||||
// That was not a hypothetical either; it is what the self-test caught on the
|
|
||||||
// third attempt, and it is why the counts are `u64` (they only ever rise) and
|
|
||||||
// the balances are `i64`.
|
|
||||||
thread_local! {
|
|
||||||
static ALLOCS: Cell<u64> = const { Cell::new(0) };
|
|
||||||
static REALLOCS: Cell<u64> = const { Cell::new(0) };
|
|
||||||
static BYTES: Cell<u64> = const { Cell::new(0) };
|
|
||||||
static LIVE: Cell<i64> = const { Cell::new(0) };
|
|
||||||
static PEAK: Cell<i64> = const { Cell::new(0) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read one monotonic counter, tolerating a thread whose locals are gone.
|
|
||||||
#[inline]
|
|
||||||
fn get(counter: &'static std::thread::LocalKey<Cell<u64>>) -> u64 {
|
|
||||||
counter.try_with(Cell::get).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add to one monotonic counter, skipping a thread whose locals are gone.
|
|
||||||
#[inline]
|
|
||||||
fn bump(counter: &'static std::thread::LocalKey<Cell<u64>>, by: u64) -> u64 {
|
|
||||||
counter
|
|
||||||
.try_with(|c| {
|
|
||||||
let v = c.get().wrapping_add(by);
|
|
||||||
c.set(v);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read one signed balance.
|
|
||||||
#[inline]
|
|
||||||
fn get_live(counter: &'static std::thread::LocalKey<Cell<i64>>) -> i64 {
|
|
||||||
counter.try_with(Cell::get).unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the live balance and return the new value.
|
|
||||||
#[inline]
|
|
||||||
fn bump_live(by: i64) -> i64 {
|
|
||||||
LIVE.try_with(|c| {
|
|
||||||
let v = c.get().wrapping_add(by);
|
|
||||||
c.set(v);
|
|
||||||
v
|
|
||||||
})
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raise the high-water mark to `live` if it is higher.
|
|
||||||
#[inline]
|
|
||||||
fn note_peak(live: i64) {
|
|
||||||
PEAK.try_with(|p| p.set(p.get().max(live))).ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `System`, with counters. Every hook delegates and then accounts; a failed
|
|
||||||
/// allocation is not counted, so the totals describe memory that really
|
|
||||||
/// existed.
|
|
||||||
struct Counting;
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
fn note_alloc(size: usize) {
|
|
||||||
bump(&ALLOCS, 1);
|
|
||||||
bump(&BYTES, size as u64);
|
|
||||||
note_peak(bump_live(size as i64));
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe impl GlobalAlloc for Counting {
|
|
||||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
|
||||||
let p = unsafe { System.alloc(layout) };
|
|
||||||
if !p.is_null() {
|
|
||||||
note_alloc(layout.size());
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
|
||||||
let p = unsafe { System.alloc_zeroed(layout) };
|
|
||||||
if !p.is_null() {
|
|
||||||
note_alloc(layout.size());
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Freeing on a thread that did not allocate drives this thread's balance
|
|
||||||
/// negative; see the note on the `thread_local!` block for why that is
|
|
||||||
/// ordinary and why the balance is signed.
|
|
||||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
|
||||||
bump_live(-(layout.size() as i64));
|
|
||||||
unsafe { System.dealloc(ptr, layout) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Counted as a resize rather than as a fresh allocation: a `Vec` doubling
|
|
||||||
/// its way up to a document's length is one buffer, not twelve, and calling
|
|
||||||
/// it twelve allocations would hide the difference between a reused buffer
|
|
||||||
/// and a per-row one — which is exactly what this harness exists to show.
|
|
||||||
/// Only the *growth* is added to traffic.
|
|
||||||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
|
||||||
let p = unsafe { System.realloc(ptr, layout, new_size) };
|
|
||||||
if !p.is_null() {
|
|
||||||
bump(&REALLOCS, 1);
|
|
||||||
let (old, new) = (layout.size() as u64, new_size as u64);
|
|
||||||
bump(&BYTES, new.saturating_sub(old));
|
|
||||||
note_peak(bump_live(new as i64 - old as i64));
|
|
||||||
}
|
|
||||||
p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[global_allocator]
|
|
||||||
static ALLOCATOR: Counting = Counting;
|
|
||||||
|
|
||||||
/// Counter values at one instant.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct Counters {
|
|
||||||
allocs: u64,
|
|
||||||
reallocs: u64,
|
|
||||||
bytes: u64,
|
|
||||||
/// Signed: a thread's balance is legitimately negative. See the
|
|
||||||
/// `thread_local!` block.
|
|
||||||
live: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What happened between two instants.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct Usage {
|
|
||||||
allocs: u64,
|
|
||||||
reallocs: u64,
|
|
||||||
bytes: u64,
|
|
||||||
peak: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Counters {
|
|
||||||
/// Snapshot, and re-arm the peak tracker at the current live figure so the
|
|
||||||
/// high-water mark that follows belongs to the region being measured
|
|
||||||
/// rather than to whatever the process did before it.
|
|
||||||
fn start() -> Counters {
|
|
||||||
let live = get_live(&LIVE);
|
|
||||||
PEAK.with(|p| p.set(live));
|
|
||||||
Counters {
|
|
||||||
allocs: get(&ALLOCS),
|
|
||||||
reallocs: get(&REALLOCS),
|
|
||||||
bytes: get(&BYTES),
|
|
||||||
live,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn since(&self) -> Usage {
|
|
||||||
Usage {
|
|
||||||
allocs: get(&ALLOCS).wrapping_sub(self.allocs),
|
|
||||||
reallocs: get(&REALLOCS).wrapping_sub(self.reallocs),
|
|
||||||
bytes: get(&BYTES).wrapping_sub(self.bytes),
|
|
||||||
// Above the live figure the region started from, so a query that
|
|
||||||
// holds nothing reads as zero rather than as the process floor.
|
|
||||||
peak: (get_live(&PEAK) - self.live).max(0) as u64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// The measurement
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Rows to seed. Smaller than `search_perf`'s 200k on purpose: allocation
|
|
||||||
/// counts are deterministic and scale linearly with rows scanned, so they need
|
|
||||||
/// no statistical settling — only enough rows that a per-row allocation is
|
|
||||||
/// unmistakable next to a per-hit one.
|
|
||||||
const NUM_FILES: usize = 50_000;
|
|
||||||
|
|
||||||
// No serializing mutex here, deliberately: the counters are per thread, so
|
|
||||||
// concurrent tests in this binary cannot reach each other's regions. An
|
|
||||||
// earlier revision did lock, and it did not work — see the comment on the
|
|
||||||
// `thread_local!` block.
|
|
||||||
|
|
||||||
fn enabled() -> bool {
|
|
||||||
std::env::var("QSB_SEARCH_ALLOC").is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One query, described so the table says which cascade passes it exercises.
|
|
||||||
struct Case {
|
|
||||||
label: &'static str,
|
|
||||||
query: &'static str,
|
|
||||||
fuzzy: bool,
|
|
||||||
/// The passes this case is here to measure, for the table's last column.
|
|
||||||
passes: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The query set.
|
|
||||||
///
|
|
||||||
/// Every case but the last is **rare on purpose**. A query that fills the
|
|
||||||
/// display limit makes `cascade::run` break out between passes, so a common
|
|
||||||
/// term measures how quickly the cascade gives up rather than what a scan
|
|
||||||
/// costs — and the passes this work targets never run at all. The rare cases
|
|
||||||
/// are also the ones a user actually waits on.
|
|
||||||
///
|
|
||||||
/// Spread across the passes deliberately: a change that helps one pass and
|
|
||||||
/// hurts another must not be able to hide inside a single total.
|
|
||||||
const CASES: &[Case] = &[
|
|
||||||
Case {
|
|
||||||
label: "no match",
|
|
||||||
query: "zzzznomatch",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "A, whole table, nothing accepted",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
label: "literal, rare",
|
|
||||||
query: "quartzite",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "A whole table + B verifies FTS hits",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
label: "wildcard, rare",
|
|
||||||
query: "quar*zite",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "A whole table, no prefilter today (S3)",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
label: "fuzzy, rare",
|
|
||||||
// One edit from the planted needle, so the exact passes miss and the
|
|
||||||
// fuzzy ones have to do the work.
|
|
||||||
query: "quartzyte",
|
|
||||||
fuzzy: true,
|
|
||||||
passes: "A + B + C + D, C and D whole-table (S1/S2/S4)",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
// The full-text pass with real work to do: the name `LIKE` finds
|
|
||||||
// nothing, so every hit is a document the trigram index returned and
|
|
||||||
// pass B had to decompress, fold and verify.
|
|
||||||
label: "content, many",
|
|
||||||
query: "chalcedony",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "A finds nothing + B verifies ~500 docs (S5/S7)",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
label: "regex, literal",
|
|
||||||
query: r"regex:quartz\w+",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "regex name + content, both prefiltered on \"quartz\"",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
// No literal to extract, so both regex passes still read everything.
|
|
||||||
// Kept beside the case above so the prefilter's *limit* is as visible
|
|
||||||
// as its win.
|
|
||||||
label: "regex, no literal",
|
|
||||||
query: r"regex:[0-9]{6}[a-y]{6}",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "regex name + content, no prefilter possible",
|
|
||||||
},
|
|
||||||
Case {
|
|
||||||
label: "common (capped)",
|
|
||||||
query: "content",
|
|
||||||
fuzzy: false,
|
|
||||||
passes: "A, stops at the display limit",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Run one query to completion on a held connection, reporting what it moved
|
|
||||||
/// through the allocator.
|
|
||||||
///
|
|
||||||
/// Hits are counted, not kept: holding thousands of `SearchHit`s would measure
|
|
||||||
/// the harness's own `Vec` rather than the cascade's. The count is reported so
|
|
||||||
/// that a change which quietly alters the result set shows up here as well as
|
|
||||||
/// in `tests/cascade.rs`.
|
|
||||||
fn measure(conn: &rusqlite::Connection, case: &Case) -> (Usage, usize, Duration) {
|
|
||||||
let split = split_for_cascade(case.query).expect("the query set parses");
|
|
||||||
let latest = AtomicU64::new(1);
|
|
||||||
let options = SearchOptions {
|
|
||||||
fuzzy: case.fuzzy,
|
|
||||||
limit: 1000,
|
|
||||||
..SearchOptions::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut count = 0usize;
|
|
||||||
let mut sink = |hits: Vec<SearchHit>| count += hits.len();
|
|
||||||
|
|
||||||
// Everything the query needs is built above; the snapshot brackets the
|
|
||||||
// cascade and nothing else.
|
|
||||||
let start = Counters::start();
|
|
||||||
let clock = Instant::now();
|
|
||||||
cascade::run(conn, &split, &options, 1, &latest, &mut sink).expect("the cascade runs");
|
|
||||||
let elapsed = clock.elapsed();
|
|
||||||
(start.since(), count, elapsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mib(bytes: u64) -> String {
|
|
||||||
format!("{:.1}", bytes as f64 / (1024.0 * 1024.0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Printed rather than asserted, for the reason `search_perf` gives: a
|
|
||||||
/// threshold on a shared machine gets muted rather than fixed. Allocation
|
|
||||||
/// counts are far more stable than timings, so a *ratchet* becomes reasonable
|
|
||||||
/// once the search work has landed and the numbers have settled — until then
|
|
||||||
/// this exists to be read when a change claims to have reduced churn.
|
|
||||||
#[test]
|
|
||||||
fn allocation_traffic_per_query() {
|
|
||||||
if !enabled() {
|
|
||||||
eprintln!("skipping: set QSB_SEARCH_ALLOC=1 to run");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = scratch_db("searchalloc");
|
|
||||||
let seeded = Instant::now();
|
|
||||||
seed_index(
|
|
||||||
&db,
|
|
||||||
&SeedSpec {
|
|
||||||
files: NUM_FILES,
|
|
||||||
..SeedSpec::default()
|
|
||||||
},
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"seeded {} rows in {:.1?} ({} MiB on disk)\n",
|
|
||||||
NUM_FILES,
|
|
||||||
seeded.elapsed(),
|
|
||||||
std::fs::metadata(&db).map(|m| m.len()).unwrap_or(0) / (1024 * 1024)
|
|
||||||
);
|
|
||||||
|
|
||||||
// One connection for the whole run, as the search worker holds one across
|
|
||||||
// a typing session. Opened through the real entry point so the pragma
|
|
||||||
// profile is the production one.
|
|
||||||
let conn = quicksearch_core::db::open::open_search_reader(&db.to_string_lossy())
|
|
||||||
.expect("open the seeded index");
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"{:<16} {:>12} {:>10} {:>12} {:>12} {:>7} {:>9} {}",
|
|
||||||
"case", "allocs", "reallocs", "bytes (MiB)", "peak (MiB)", "hits", "time", "passes"
|
|
||||||
);
|
|
||||||
for case in CASES {
|
|
||||||
// Once to warm the page cache and the statement cache, then measured:
|
|
||||||
// a cold first query would report SQLite's one-off setup as the
|
|
||||||
// cascade's churn.
|
|
||||||
let _ = measure(&conn, case);
|
|
||||||
let (usage, hits, elapsed) = measure(&conn, case);
|
|
||||||
println!(
|
|
||||||
"{:<16} {:>12} {:>10} {:>12} {:>12} {:>7} {:>9.1?} {}",
|
|
||||||
case.label,
|
|
||||||
usage.allocs,
|
|
||||||
usage.reallocs,
|
|
||||||
mib(usage.bytes),
|
|
||||||
mib(usage.peak),
|
|
||||||
hits,
|
|
||||||
elapsed,
|
|
||||||
case.passes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"\n{} rows scanned per whole-table pass. An `allocs` figure at or above \
|
|
||||||
that is per-row work;\nafter S1/S2 the scan passes should sit near their \
|
|
||||||
hit counts instead.",
|
|
||||||
NUM_FILES
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The accounting itself, so a number printed above is a number that means
|
|
||||||
/// something. A harness that silently stopped counting would read as a
|
|
||||||
/// spectacular optimization.
|
|
||||||
#[test]
|
|
||||||
fn the_counters_track_real_allocations() {
|
|
||||||
let start = Counters::start();
|
|
||||||
// A `Vec` that grows by doubling is one buffer: one alloc, then reallocs.
|
|
||||||
let mut v: Vec<u8> = Vec::new();
|
|
||||||
for _ in 0..64 * 1024 {
|
|
||||||
v.push(0);
|
|
||||||
}
|
|
||||||
let grown = start.since();
|
|
||||||
assert_eq!(grown.allocs, 1, "a doubling Vec is one allocation");
|
|
||||||
assert!(grown.reallocs > 0, "and several resizes");
|
|
||||||
assert!(
|
|
||||||
grown.peak >= 64 * 1024,
|
|
||||||
"peak {} should cover the grown buffer",
|
|
||||||
grown.peak
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dropping it returns the bytes: live falls back, so a later region's peak
|
|
||||||
// is not inflated by this one.
|
|
||||||
let before_drop = get_live(&LIVE);
|
|
||||||
drop(v);
|
|
||||||
assert!(
|
|
||||||
get_live(&LIVE) < before_drop,
|
|
||||||
"dealloc must decrement live bytes"
|
|
||||||
);
|
|
||||||
|
|
||||||
// The signedness the whole scheme turns on. Sink the balance below zero, as
|
|
||||||
// a thread that frees what another thread allocated really does, and check
|
|
||||||
// that a peak is still reported: held unsigned, that negative balance reads
|
|
||||||
// as ~1.8e19, `max` latches onto it, and every peak in the table is
|
|
||||||
// reported as a handful of bytes forever.
|
|
||||||
bump_live(-(1 << 20));
|
|
||||||
let negative = Counters::start();
|
|
||||||
assert!(negative.live < 0, "the balance is genuinely negative");
|
|
||||||
let mut grow: Vec<u8> = Vec::with_capacity(32 * 1024);
|
|
||||||
grow.push(1);
|
|
||||||
let seen = negative.since().peak;
|
|
||||||
drop(grow);
|
|
||||||
assert!(
|
|
||||||
seen >= 32 * 1024,
|
|
||||||
"a negative live balance swallowed the peak: {}",
|
|
||||||
seen
|
|
||||||
);
|
|
||||||
bump_live(1 << 20); // put back what was sunk, so later regions start clean
|
|
||||||
|
|
||||||
// The property that makes the whole harness trustworthy under libtest's
|
|
||||||
// thread-per-test: another thread allocating hard does not touch this
|
|
||||||
// thread's counters.
|
|
||||||
//
|
|
||||||
// The thread is spawned and joined *outside* the region on purpose.
|
|
||||||
// `spawn` boxes the closure and allocates the `JoinHandle` on the calling
|
|
||||||
// thread, and `join` frees them there — so bracketing the spawn would
|
|
||||||
// measure this thread's own bookkeeping and report it as leakage. A
|
|
||||||
// barrier hands control across without allocating.
|
|
||||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
|
||||||
let child = {
|
|
||||||
let barrier = barrier.clone();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
barrier.wait(); // the region is open
|
|
||||||
let noisy: Vec<String> = (0..10_000).map(|i| format!("allocation {}", i)).collect();
|
|
||||||
std::hint::black_box(noisy.len());
|
|
||||||
barrier.wait(); // the noise is done
|
|
||||||
})
|
|
||||||
};
|
|
||||||
let quiet_across_threads = Counters::start();
|
|
||||||
barrier.wait();
|
|
||||||
barrier.wait();
|
|
||||||
let leaked = quiet_across_threads.since();
|
|
||||||
child.join().expect("the noisy thread finishes");
|
|
||||||
assert_eq!(
|
|
||||||
(leaked.allocs, leaked.bytes),
|
|
||||||
(0, 0),
|
|
||||||
"another thread's allocations must not be charged to this region"
|
|
||||||
);
|
|
||||||
|
|
||||||
// A region that allocates nothing reports nothing, which is what makes
|
|
||||||
// "this pass no longer allocates per row" a statement the table can make.
|
|
||||||
let quiet = Counters::start();
|
|
||||||
std::hint::black_box(1u64 + 1);
|
|
||||||
let idle = quiet.since();
|
|
||||||
assert_eq!((idle.allocs, idle.bytes, idle.peak), (0, 0, 0));
|
|
||||||
}
|
|
||||||
|
|
@ -114,12 +114,13 @@ fn seed(path: &std::path::Path) {
|
||||||
let w1 = WORDS[(rng.next() as usize) % WORDS.len()];
|
let w1 = WORDS[(rng.next() as usize) % WORDS.len()];
|
||||||
let w2 = WORDS[(rng.next() as usize) % WORDS.len()];
|
let w2 = WORDS[(rng.next() as usize) % WORDS.len()];
|
||||||
let name = format!("{}-{}-{:07}.txt", w1, w2, i);
|
let name = format!("{}-{}-{:07}.txt", w1, w2, i);
|
||||||
// Stored parents always end in a separator.
|
let dir = format!("/seed/{:03}", i % 500);
|
||||||
let dir = format!("/seed/{:03}/", i % 500);
|
let full = format!("{}/{}", dir, name);
|
||||||
let id = insert_file(
|
let id = insert_file(
|
||||||
&tx,
|
&tx,
|
||||||
&NewFile {
|
&NewFile {
|
||||||
name: &name,
|
name: &name,
|
||||||
|
path: &full,
|
||||||
parent: &dir,
|
parent: &dir,
|
||||||
size: 4096,
|
size: 4096,
|
||||||
mtime: 1_700_000_000 + i as u64,
|
mtime: 1_700_000_000 + i as u64,
|
||||||
|
|
|
||||||
|
|
@ -285,7 +285,7 @@ fn snippet_paths_perf_comparison() {
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
let folded = text.to_ascii_lowercase();
|
let folded = text.to_ascii_lowercase();
|
||||||
let _snip = snippet::extract_folded(&text, &folded, &[q], &opts).0;
|
let _snip = snippet::extract_folded(&text, &folded, &[q], &opts);
|
||||||
rows_b_total += 1;
|
rows_b_total += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ use quicksearch_core::db;
|
||||||
use quicksearch_core::indexing::{
|
use quicksearch_core::indexing::{
|
||||||
overall_progress, ConfigChange, IndexingStatus, PrepStep, RootPhase, RootProgress,
|
overall_progress, ConfigChange, IndexingStatus, PrepStep, RootPhase, RootProgress,
|
||||||
};
|
};
|
||||||
use quicksearch_core::platform::{IndexLock, LockError};
|
|
||||||
use quicksearch_core::search::SearchOptions;
|
use quicksearch_core::search::SearchOptions;
|
||||||
use quicksearch_core::security::{derive_key, generate_salt, salt_to_hex, IndexKey};
|
use quicksearch_core::security::{derive_key, generate_salt, salt_to_hex, IndexKey};
|
||||||
use quicksearch_core::watcher::WatchError;
|
use quicksearch_core::watcher::WatchError;
|
||||||
|
|
@ -323,45 +322,10 @@ impl QuickSearchApp {
|
||||||
.watch_cap_warned_roots
|
.watch_cap_warned_roots
|
||||||
.retain(|root| new.paths.indexing_paths.contains(root));
|
.retain(|root| new.paths.indexing_paths.contains(root));
|
||||||
let actions = diff_actions(&self.cfg, &new);
|
let actions = diff_actions(&self.cfg, &new);
|
||||||
// The instance lock follows the database path, and it has to move
|
|
||||||
// before the change is written: a `database_path` that another
|
|
||||||
// instance already holds must never reach the config file, or the next
|
|
||||||
// launch reads it and refuses to start at all. `move_to` takes the new
|
|
||||||
// lock before dropping the old, so this rejection leaves us holding
|
|
||||||
// what we already had.
|
|
||||||
if actions.search_db_changed {
|
|
||||||
match IndexLock::move_to(&new.resolved_database_path()) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(LockError::Held { pid }) => {
|
|
||||||
let who = match pid {
|
|
||||||
Some(pid) => format!(" (process {})", pid),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
self.config_error = Some(format!(
|
|
||||||
"Not applied: another QuickSearch{} is using that index.",
|
|
||||||
who
|
|
||||||
));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// The move happened and the new path simply cannot be locked.
|
|
||||||
// Same rule as at startup: a convenience guard is never a good
|
|
||||||
// enough reason to refuse.
|
|
||||||
Err(LockError::Unsupported(why)) => {
|
|
||||||
quicksearch_core::log_warn!("cannot lock the index ({}); continuing", why);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// A config that could not be written must not take effect either: it
|
// A config that could not be written must not take effect either: it
|
||||||
// would apply to this process, revert on restart, and show nothing
|
// would apply to this process, revert on restart, and show nothing
|
||||||
// unsaved in between.
|
// unsaved in between.
|
||||||
if let Err(e) = new.save() {
|
if let Err(e) = new.save() {
|
||||||
// Put the lock back on the path the config still names, or this
|
|
||||||
// process would go on using the old index while guarding the new
|
|
||||||
// one — leaving the index it is actually writing open to a second
|
|
||||||
// instance.
|
|
||||||
if actions.search_db_changed {
|
|
||||||
let _ = IndexLock::move_to(&self.cfg.resolved_database_path());
|
|
||||||
}
|
|
||||||
self.config_error = Some(e);
|
self.config_error = Some(e);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -386,7 +350,7 @@ impl QuickSearchApp {
|
||||||
self.backend.coordinator.apply_config(new.clone());
|
self.backend.coordinator.apply_config(new.clone());
|
||||||
if actions.requires_rebuild {
|
if actions.requires_rebuild {
|
||||||
if self.backend.coordinator.state().mode == IndexMode::Auto {
|
if self.backend.coordinator.state().mode == IndexMode::Auto {
|
||||||
self.backend.rebuild_index();
|
self.backend.coordinator.rebuild_index();
|
||||||
} else {
|
} else {
|
||||||
let changes = self
|
let changes = self
|
||||||
.backend
|
.backend
|
||||||
|
|
@ -476,9 +440,7 @@ impl QuickSearchApp {
|
||||||
if let Some(rx) = &self.backend.dup_job {
|
if let Some(rx) = &self.backend.dup_job {
|
||||||
use std::sync::mpsc::TryRecvError;
|
use std::sync::mpsc::TryRecvError;
|
||||||
let done = match rx.try_recv() {
|
let done = match rx.try_recv() {
|
||||||
Ok(Ok(groups)) => Some(DupState::Loaded(crate::duplicates_tab::LoadedGroups::new(
|
Ok(Ok(groups)) => Some(DupState::Loaded(groups)),
|
||||||
groups,
|
|
||||||
))),
|
|
||||||
Ok(Err(e)) => Some(DupState::Error(e)),
|
Ok(Err(e)) => Some(DupState::Error(e)),
|
||||||
Err(TryRecvError::Empty) => None,
|
Err(TryRecvError::Empty) => None,
|
||||||
Err(TryRecvError::Disconnected) => {
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
|
@ -730,8 +692,7 @@ impl eframe::App for QuickSearchApp {
|
||||||
if let Some(paths) = actions.verify {
|
if let Some(paths) = actions.verify {
|
||||||
let paths: Vec<std::path::PathBuf> =
|
let paths: Vec<std::path::PathBuf> =
|
||||||
paths.into_iter().map(std::path::PathBuf::from).collect();
|
paths.into_iter().map(std::path::PathBuf::from).collect();
|
||||||
self.backend
|
self.backend.start_verify(paths.clone(), ctx.clone());
|
||||||
.start_verify(paths.clone(), &self.cfg, ctx.clone());
|
|
||||||
self.verify = Some(VerifyModal::new(paths));
|
self.verify = Some(VerifyModal::new(paths));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ impl QuickSearchApp {
|
||||||
));
|
));
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.button("Rebuild now").clicked() {
|
if ui.button("Rebuild now").clicked() {
|
||||||
self.backend.rebuild_index();
|
self.backend.coordinator.rebuild_index();
|
||||||
close = true;
|
close = true;
|
||||||
}
|
}
|
||||||
if ui.button("Later").clicked() {
|
if ui.button("Later").clicked() {
|
||||||
|
|
@ -97,7 +97,7 @@ impl QuickSearchApp {
|
||||||
}
|
}
|
||||||
if stale_index_window(ctx, self.key_source) {
|
if stale_index_window(ctx, self.key_source) {
|
||||||
self.stale_index_prompt = false;
|
self.stale_index_prompt = false;
|
||||||
self.backend.rebuild_index();
|
self.backend.coordinator.rebuild_index();
|
||||||
self.dups.state = DupState::NotLoaded;
|
self.dups.state = DupState::NotLoaded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,18 +158,6 @@ impl QuickSearchApp {
|
||||||
group_thousands(*registered as u64),
|
group_thousands(*registered as u64),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
// Not reachable through this modal today: an overflow leaves
|
|
||||||
// the watcher running and schedules a full run instead of
|
|
||||||
// disabling live updates, so it never becomes a
|
|
||||||
// `WatcherStatus::Disabled`. Spelled out rather than folded
|
|
||||||
// into a catch-all so that a future variant is a compile
|
|
||||||
// error here, which is how this arm came to be written.
|
|
||||||
WatchError::Overflowed => {
|
|
||||||
ui.label(
|
|
||||||
"The system dropped some change notifications, so the index \
|
|
||||||
is being rebuilt to catch up.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
WatchError::Other(msg) => {
|
WatchError::Other(msg) => {
|
||||||
ui.label(format!("Live updates are unavailable: {}", msg));
|
ui.label(format!("Live updates are unavailable: {}", msg));
|
||||||
}
|
}
|
||||||
|
|
@ -239,7 +227,7 @@ impl QuickSearchApp {
|
||||||
// resurrect what was just deleted, nor the next launch
|
// resurrect what was just deleted, nor the next launch
|
||||||
// undo the stop.
|
// undo the stop.
|
||||||
self.set_index_mode(false);
|
self.set_index_mode(false);
|
||||||
self.backend.clear_index();
|
self.backend.coordinator.clear_index();
|
||||||
self.dups.state = DupState::NotLoaded;
|
self.dups.state = DupState::NotLoaded;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -377,7 +377,7 @@ impl QuickSearchApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db::set_process_key(new_key);
|
db::set_process_key(new_key);
|
||||||
self.backend.rebuild_index();
|
self.backend.coordinator.rebuild_index();
|
||||||
self.dups.state = DupState::NotLoaded;
|
self.dups.state = DupState::NotLoaded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,31 +51,6 @@ pub struct Backend {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Backend {
|
impl Backend {
|
||||||
/// Ask the coordinator to rebuild, after letting go of everything that
|
|
||||||
/// holds the index file open.
|
|
||||||
///
|
|
||||||
/// The search worker keeps its connection for half a minute after the last
|
|
||||||
/// keystroke so a typing session runs against a warm cache — which is
|
|
||||||
/// exactly the wrong thing to be holding when the file is about to be
|
|
||||||
/// deleted. Without this the delete fails on Windows and the rebuild
|
|
||||||
/// silently becomes an ordinary run against the old index; after a
|
|
||||||
/// password change that leaves the config claiming protection the file on
|
|
||||||
/// disk does not have.
|
|
||||||
pub fn rebuild_index(&self) {
|
|
||||||
if let Some(search) = &self.search {
|
|
||||||
search.release_connection();
|
|
||||||
}
|
|
||||||
self.coordinator.rebuild_index();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`Backend::rebuild_index`]'s reasoning, for the delete-only path.
|
|
||||||
pub fn clear_index(&self) {
|
|
||||||
if let Some(search) = &self.search {
|
|
||||||
search.release_connection();
|
|
||||||
}
|
|
||||||
self.coordinator.clear_index();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start(config: &Config, ctx: egui::Context) -> Result<Backend, String> {
|
pub fn start(config: &Config, ctx: egui::Context) -> Result<Backend, String> {
|
||||||
// eframe is reactive: a run the coordinator schedules on its own
|
// eframe is reactive: a run the coordinator schedules on its own
|
||||||
// would sit unseen behind a settled window until the pointer moved.
|
// would sit unseen behind a settled window until the pointer moved.
|
||||||
|
|
@ -114,17 +89,10 @@ impl Backend {
|
||||||
pub fn watch_live(
|
pub fn watch_live(
|
||||||
&self,
|
&self,
|
||||||
query: &str,
|
query: &str,
|
||||||
mut targets: Vec<quicksearch_core::live::Target>,
|
targets: Vec<quicksearch_core::live::Target>,
|
||||||
config: &Config,
|
config: &Config,
|
||||||
) {
|
) {
|
||||||
let Some(live) = &self.live else { return };
|
let Some(live) = &self.live else { return };
|
||||||
// The watcher re-reads a row's file to re-cut its snippet, and the one
|
|
||||||
// file it must never open is the index it is reading the row from:
|
|
||||||
// closing a descriptor on it cancels SQLite's locks process-wide. The
|
|
||||||
// walk no longer writes such rows, but one from an older build lives
|
|
||||||
// until the stale sweep reaches its directory, and it can be on screen
|
|
||||||
// before then.
|
|
||||||
targets.retain(|t| !config.is_index_file(std::path::Path::new(&t.path)));
|
|
||||||
if targets.is_empty() {
|
if targets.is_empty() {
|
||||||
live.clear();
|
live.clear();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -165,13 +133,10 @@ impl Backend {
|
||||||
|
|
||||||
/// Read a duplicate group through on a worker thread, comparing every
|
/// Read a duplicate group through on a worker thread, comparing every
|
||||||
/// member against the first byte for byte. Replaces any run already going.
|
/// member against the first byte for byte. Replaces any run already going.
|
||||||
pub fn start_verify(&mut self, mut paths: Vec<PathBuf>, config: &Config, ctx: egui::Context) {
|
pub fn start_verify(&mut self, paths: Vec<PathBuf>, ctx: egui::Context) {
|
||||||
if let Some(job) = &self.verify_job {
|
if let Some(job) = &self.verify_job {
|
||||||
job.cancel();
|
job.cancel();
|
||||||
}
|
}
|
||||||
// Byte-for-byte comparison opens every member. See `watch_live` for
|
|
||||||
// why the index must not be one of them.
|
|
||||||
paths.retain(|p| !config.is_index_file(p));
|
|
||||||
let (tx, rx) = mpsc::channel();
|
let (tx, rx) = mpsc::channel();
|
||||||
let cancel = Arc::new(AtomicBool::new(false));
|
let cancel = Arc::new(AtomicBool::new(false));
|
||||||
let worker_cancel = cancel.clone();
|
let worker_cancel = cancel.clone();
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ use quicksearch_core::db;
|
||||||
use quicksearch_core::query::split::split_for_cascade;
|
use quicksearch_core::query::split::split_for_cascade;
|
||||||
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
use quicksearch_core::search::{cascade, SearchHit, SearchOptions};
|
||||||
use quicksearch_core::security::{derive_key, IndexKey};
|
use quicksearch_core::security::{derive_key, IndexKey};
|
||||||
use quicksearch_core::textenc::scrub_controls;
|
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::format::{fmt_mtime, human_size};
|
use crate::format::{fmt_mtime, human_size};
|
||||||
|
|
@ -128,22 +127,15 @@ pub(crate) fn resolve_key(
|
||||||
|
|
||||||
if let Some(hex) = keychain_hex {
|
if let Some(hex) = keychain_hex {
|
||||||
match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) {
|
match IndexKey::from_hex(&hex).map_err(|e| format!("keychain entry: {}", e)) {
|
||||||
Ok(key) => match try_key(key) {
|
Ok(key) => {
|
||||||
Ok(()) => return Ok(()),
|
match try_key(key) {
|
||||||
Err(e) => match mismatch_cause(&e) {
|
Ok(()) => return Ok(()),
|
||||||
Some((db::KeyMismatch::WrongPassword, _)) => {
|
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => {
|
||||||
eprintln!(
|
eprintln!("warning: the key remembered in the OS keychain no longer opens this index");
|
||||||
"warning: the key remembered in the OS keychain no longer \
|
|
||||||
opens this index"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// Not a stale keychain entry at all: the index is not
|
Err(e) => return Err(e),
|
||||||
// encrypted, or wants a key this one is not. Say which,
|
}
|
||||||
// then fall through to the other sources as before.
|
}
|
||||||
Some((_, detail)) => eprintln!("warning: {}", detail),
|
|
||||||
None => return Err(e),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Err(e) => eprintln!("warning: {}", e),
|
Err(e) => eprintln!("warning: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -153,17 +145,11 @@ pub(crate) fn resolve_key(
|
||||||
drop(password);
|
drop(password);
|
||||||
return match try_key(key) {
|
return match try_key(key) {
|
||||||
Ok(()) => Ok(()),
|
Ok(()) => Ok(()),
|
||||||
Err(e) => match mismatch_cause(&e) {
|
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => Err(format!(
|
||||||
Some((db::KeyMismatch::WrongPassword, _)) => Err(format!(
|
"{} does not match this index's password",
|
||||||
"{} does not match this index's password",
|
PASSWORD_ENV
|
||||||
PASSWORD_ENV
|
)),
|
||||||
)),
|
Err(e) => Err(e),
|
||||||
// Blaming the environment variable for "the index is not
|
|
||||||
// encrypted" sends the user to change the one thing that is
|
|
||||||
// not wrong.
|
|
||||||
Some((_, detail)) => Err(detail),
|
|
||||||
None => Err(e),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -182,31 +168,15 @@ pub(crate) fn resolve_key(
|
||||||
drop(password);
|
drop(password);
|
||||||
match try_key(key) {
|
match try_key(key) {
|
||||||
Ok(()) => return Ok(()),
|
Ok(()) => return Ok(()),
|
||||||
Err(e) => match mismatch_cause(&e) {
|
Err(e) if e.starts_with(db::KEY_MISMATCH_PREFIX) => {
|
||||||
// Only a wrong password is worth another attempt. The other
|
eprintln!("Wrong password.");
|
||||||
// two causes are facts about the index — it is not encrypted,
|
}
|
||||||
// or it wants a password this build did not apply — and
|
Err(e) => return Err(e),
|
||||||
// retrying makes the user type a *correct* password twice more
|
|
||||||
// before being told something that was never about their
|
|
||||||
// typing. The same reasoning as the unlock screen's.
|
|
||||||
Some((db::KeyMismatch::WrongPassword, _)) => eprintln!("Wrong password."),
|
|
||||||
Some((_, detail)) => return Err(detail),
|
|
||||||
None => return Err(e),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err("wrong password (3 attempts)".to_string())
|
Err("wrong password (3 attempts)".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`db::key_mismatch_parts`] with the detail copied out.
|
|
||||||
///
|
|
||||||
/// Owned rather than borrowed so a caller can hand the original error back on
|
|
||||||
/// the `None` arm: `key_mismatch_parts` borrows from the message, and a `match`
|
|
||||||
/// on its result would hold that borrow across a `return Err(e)`.
|
|
||||||
fn mismatch_cause(error: &str) -> Option<(db::KeyMismatch, String)> {
|
|
||||||
db::key_mismatch_parts(error).map(|(cause, detail)| (cause, detail.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Wire [`resolve_key`] to the real terminal, environment, keychain and
|
/// Wire [`resolve_key`] to the real terminal, environment, keychain and
|
||||||
/// database, installing the verified key as the process key.
|
/// database, installing the verified key as the process key.
|
||||||
fn resolve_key_for_terminal(security: &SecurityConfig, db_path: &str) -> Result<(), String> {
|
fn resolve_key_for_terminal(security: &SecurityConfig, db_path: &str) -> Result<(), String> {
|
||||||
|
|
@ -299,21 +269,7 @@ fn run_query(query: &str, fuzzy: bool, limit: Option<usize>, long: bool) -> i32
|
||||||
|
|
||||||
match outcome {
|
match outcome {
|
||||||
Ok(Some(outcome)) => {
|
Ok(Some(outcome)) => {
|
||||||
let tty = std::io::stdout().is_terminal();
|
let color = long && std::io::stdout().is_terminal() && enable_vt();
|
||||||
let color = long && tty && enable_vt();
|
|
||||||
// Scrubbed only for a terminal, the way `ls` does it and the way
|
|
||||||
// the colour above already decides: a filename may contain escape
|
|
||||||
// sequences, and printed raw they rewrite the line, retitle the
|
|
||||||
// window, or reach the clipboard on a terminal with OSC 52 on.
|
|
||||||
// Piped output stays byte-exact, because the next program in the
|
|
||||||
// pipe wants the real name and interprets nothing.
|
|
||||||
let show = |s: &str| -> String {
|
|
||||||
if tty {
|
|
||||||
scrub_controls(s).into_owned()
|
|
||||||
} else {
|
|
||||||
s.to_string()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
for hit in &hits {
|
for hit in &hits {
|
||||||
if long {
|
if long {
|
||||||
println!(
|
println!(
|
||||||
|
|
@ -321,13 +277,13 @@ fn run_query(query: &str, fuzzy: bool, limit: Option<usize>, long: bool) -> i32
|
||||||
hit.rank,
|
hit.rank,
|
||||||
human_size(hit.size),
|
human_size(hit.size),
|
||||||
fmt_mtime(hit.mtime),
|
fmt_mtime(hit.mtime),
|
||||||
show(&hit.path)
|
hit.path
|
||||||
);
|
);
|
||||||
if let Some(snip) = &hit.snippet {
|
if let Some(snip) = &hit.snippet {
|
||||||
println!(" {}", render_snippet(snip, color, tty));
|
println!(" {}", render_snippet(snip, color));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
println!("{}", show(&hit.path));
|
println!("{}", hit.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if outcome.limited {
|
if outcome.limited {
|
||||||
|
|
@ -378,58 +334,28 @@ fn enable_vt() -> bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-line snippet with matches emphasized (ANSI bold on TTYs).
|
/// One-line snippet with matches emphasized (ANSI bold on TTYs).
|
||||||
fn render_snippet(snip: &quicksearch_core::snippet::Snippet, color: bool, tty: bool) -> String {
|
fn render_snippet(snip: &quicksearch_core::snippet::Snippet, color: bool) -> String {
|
||||||
// The window is document text, so it can hold anything the file did —
|
|
||||||
// including the escape sequences this function is about to add its own
|
|
||||||
// emphasis codes around. Each piece is scrubbed as it goes in, before the
|
|
||||||
// codes, so the only escapes in the result are the ones put there here.
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
let push = |out: &mut String, piece: &str| {
|
|
||||||
// Line breaks are flattened *before* the scrub, not after the whole
|
|
||||||
// string is assembled. A snippet is one line by contract for both
|
|
||||||
// destinations, and `scrub_controls` turns `\n` into `U+FFFD` — so a
|
|
||||||
// trailing `replace(['\n', '\r'], " ")` would find nothing left to
|
|
||||||
// collapse on a terminal and print `<60>` where the space belongs.
|
|
||||||
let flat = flatten_lines(piece);
|
|
||||||
if tty {
|
|
||||||
out.push_str(&scrub_controls(&flat));
|
|
||||||
} else {
|
|
||||||
out.push_str(&flat);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if snip.truncated_start {
|
if snip.truncated_start {
|
||||||
out.push('…');
|
out.push('…');
|
||||||
}
|
}
|
||||||
let mut cursor = 0;
|
let mut cursor = 0;
|
||||||
for &(start, end) in &snip.ranges {
|
for &(start, end) in &snip.ranges {
|
||||||
push(&mut out, &snip.window[cursor..start]);
|
out.push_str(&snip.window[cursor..start]);
|
||||||
if color {
|
if color {
|
||||||
out.push_str("\x1b[1m");
|
out.push_str("\x1b[1m");
|
||||||
push(&mut out, &snip.window[start..end]);
|
out.push_str(&snip.window[start..end]);
|
||||||
out.push_str("\x1b[0m");
|
out.push_str("\x1b[0m");
|
||||||
} else {
|
} else {
|
||||||
push(&mut out, &snip.window[start..end]);
|
out.push_str(&snip.window[start..end]);
|
||||||
}
|
}
|
||||||
cursor = end;
|
cursor = end;
|
||||||
}
|
}
|
||||||
push(&mut out, &snip.window[cursor..]);
|
out.push_str(&snip.window[cursor..]);
|
||||||
if snip.truncated_end {
|
if snip.truncated_end {
|
||||||
out.push('…');
|
out.push('…');
|
||||||
}
|
}
|
||||||
out
|
out.replace(['\n', '\r'], " ")
|
||||||
}
|
|
||||||
|
|
||||||
/// `piece` with line breaks flattened to spaces, borrowed when it has none.
|
|
||||||
///
|
|
||||||
/// A `Cow` for the same reason [`scrub_controls`] is one: this runs per
|
|
||||||
/// fragment of every snippet of every hit, and the overwhelmingly common case
|
|
||||||
/// has nothing to change.
|
|
||||||
fn flatten_lines(piece: &str) -> std::borrow::Cow<'_, str> {
|
|
||||||
if piece.contains(['\n', '\r']) {
|
|
||||||
std::borrow::Cow::Owned(piece.replace(['\n', '\r'], " "))
|
|
||||||
} else {
|
|
||||||
std::borrow::Cow::Borrowed(piece)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -446,10 +372,7 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mismatch() -> Result<(), String> {
|
fn mismatch() -> Result<(), String> {
|
||||||
Err(format!(
|
Err(format!("{}wrong password", db::KEY_MISMATCH_PREFIX))
|
||||||
"{}wrong-password: index at /x: wrong password",
|
|
||||||
db::KEY_MISMATCH_PREFIX
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn argv(args: &[&str]) -> Vec<String> {
|
fn argv(args: &[&str]) -> Vec<String> {
|
||||||
|
|
|
||||||
|
|
@ -9,45 +9,10 @@ use crate::ui_util::hint;
|
||||||
pub enum DupState {
|
pub enum DupState {
|
||||||
NotLoaded,
|
NotLoaded,
|
||||||
Loading,
|
Loading,
|
||||||
Loaded(LoadedGroups),
|
Loaded(Vec<DuplicateGroup>),
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The scan's result, with each group's header line already built.
|
|
||||||
///
|
|
||||||
/// The titles are four formatted numbers each and the list runs to 500, so
|
|
||||||
/// building them in the render loop meant ~2000 allocations *per frame* — and
|
|
||||||
/// this list overflows by definition, which keeps `more_below_hint`'s 20 Hz
|
|
||||||
/// repaint running for as long as the tab is open. They depend only on the
|
|
||||||
/// data, so they are built once, here, where the data arrives.
|
|
||||||
pub struct LoadedGroups {
|
|
||||||
pub groups: Vec<DuplicateGroup>,
|
|
||||||
titles: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoadedGroups {
|
|
||||||
pub fn new(groups: Vec<DuplicateGroup>) -> LoadedGroups {
|
|
||||||
let titles = groups
|
|
||||||
.iter()
|
|
||||||
.map(|group| {
|
|
||||||
let name = group
|
|
||||||
.members
|
|
||||||
.first()
|
|
||||||
.map(|m| m.1.as_str())
|
|
||||||
.unwrap_or("(unknown)");
|
|
||||||
format!(
|
|
||||||
"{} × {}: {} reclaimable ({} total)",
|
|
||||||
group_thousands(group.count as u64),
|
|
||||||
name,
|
|
||||||
human_size(group.redundant_size.max(0) as u64),
|
|
||||||
human_size(group.total_size.max(0) as u64),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
LoadedGroups { groups, titles }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct DuplicatesTab {
|
pub struct DuplicatesTab {
|
||||||
pub state: DupState,
|
pub state: DupState,
|
||||||
}
|
}
|
||||||
|
|
@ -108,8 +73,7 @@ impl DuplicatesTab {
|
||||||
DupState::Error(e) => {
|
DupState::Error(e) => {
|
||||||
ui.colored_label(ui.visuals().error_fg_color, e);
|
ui.colored_label(ui.visuals().error_fg_color, e);
|
||||||
}
|
}
|
||||||
DupState::Loaded(loaded) => {
|
DupState::Loaded(groups) => {
|
||||||
let groups = &loaded.groups;
|
|
||||||
if groups.is_empty() {
|
if groups.is_empty() {
|
||||||
ui.label("No duplicate files found.");
|
ui.label("No duplicate files found.");
|
||||||
return actions;
|
return actions;
|
||||||
|
|
@ -121,9 +85,18 @@ impl DuplicatesTab {
|
||||||
.auto_shrink([false; 2])
|
.auto_shrink([false; 2])
|
||||||
.show(ui, |ui| {
|
.show(ui, |ui| {
|
||||||
for (i, group) in groups.iter().enumerate() {
|
for (i, group) in groups.iter().enumerate() {
|
||||||
// Built once when the scan landed; see
|
let name = group
|
||||||
// `LoadedGroups`.
|
.members
|
||||||
let title = loaded.titles[i].as_str();
|
.first()
|
||||||
|
.map(|m| m.1.as_str())
|
||||||
|
.unwrap_or("(unknown)");
|
||||||
|
let title = format!(
|
||||||
|
"{} × {}: {} reclaimable ({} total)",
|
||||||
|
group_thousands(group.count as u64),
|
||||||
|
name,
|
||||||
|
human_size(group.redundant_size.max(0) as u64),
|
||||||
|
human_size(group.total_size.max(0) as u64),
|
||||||
|
);
|
||||||
let header =
|
let header =
|
||||||
egui::CollapsingHeader::new(title)
|
egui::CollapsingHeader::new(title)
|
||||||
.id_salt(i)
|
.id_salt(i)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ fn group(paths: &[&str]) -> DuplicateGroup {
|
||||||
|
|
||||||
fn loaded(paths: &[&str]) -> DuplicatesTab {
|
fn loaded(paths: &[&str]) -> DuplicatesTab {
|
||||||
DuplicatesTab {
|
DuplicatesTab {
|
||||||
state: DupState::Loaded(LoadedGroups::new(vec![group(paths)])),
|
state: DupState::Loaded(vec![group(paths)]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -177,7 +177,7 @@ fn a_member_row_offers_the_verification_too() {
|
||||||
fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
|
fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
|
||||||
let ctx = crate::test_ui::ctx();
|
let ctx = crate::test_ui::ctx();
|
||||||
let mut tab = DuplicatesTab {
|
let mut tab = DuplicatesTab {
|
||||||
state: DupState::Loaded(LoadedGroups::new(Vec::new())),
|
state: DupState::Loaded(Vec::new()),
|
||||||
};
|
};
|
||||||
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
|
let painted = painted_text(&frame(&ctx, &mut tab, false, Vec::new()).0);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -43,14 +43,10 @@ pub fn fmt_ago(unix_secs: u64) -> String {
|
||||||
/// A configured interval as a phrase to drop after "every": `90 min`,
|
/// A configured interval as a phrase to drop after "every": `90 min`,
|
||||||
/// `24 h`, `3 days`.
|
/// `24 h`, `3 days`.
|
||||||
pub fn fmt_interval(minutes: u64) -> String {
|
pub fn fmt_interval(minutes: u64) -> String {
|
||||||
// The same clamp the scheduler applies. Zero is only reachable from a
|
if minutes == 0 {
|
||||||
// hand-edited config — the spinner's floor is 5 — and it used to read
|
// The scheduler treats 0 as always-due.
|
||||||
// "run", from a time when the scheduler took it as always-due. It no
|
return "run".to_string();
|
||||||
// longer does (`coordinator::inner::periodic_due` raises it to one
|
}
|
||||||
// minute, because "reindex continuously" is not a setting anyone wants
|
|
||||||
// and manual mode is how you say "never"), so saying "run" here would
|
|
||||||
// describe a behaviour the app does not have.
|
|
||||||
let minutes = minutes.max(1);
|
|
||||||
if minutes < 60 {
|
if minutes < 60 {
|
||||||
return format!("{} min", minutes);
|
return format!("{} min", minutes);
|
||||||
}
|
}
|
||||||
|
|
@ -174,9 +170,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intervals() {
|
fn intervals() {
|
||||||
// Clamped, exactly as `periodic_due` clamps it: the two must not
|
assert_eq!(fmt_interval(0), "run");
|
||||||
// describe different behaviour.
|
|
||||||
assert_eq!(fmt_interval(0), "1 min");
|
|
||||||
assert_eq!(fmt_interval(1), "1 min");
|
assert_eq!(fmt_interval(1), "1 min");
|
||||||
assert_eq!(fmt_interval(59), "59 min");
|
assert_eq!(fmt_interval(59), "59 min");
|
||||||
assert_eq!(fmt_interval(60), "1 h", "the shipped default");
|
assert_eq!(fmt_interval(60), "1 h", "the shipped default");
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,6 @@ mod unlock;
|
||||||
mod version;
|
mod version;
|
||||||
|
|
||||||
use quicksearch_core::config::Config;
|
use quicksearch_core::config::Config;
|
||||||
use quicksearch_core::platform::{IndexLock, LockError};
|
|
||||||
|
|
||||||
/// The window icon, shown in the titlebar, taskbar and alt-tab switcher.
|
/// The window icon, shown in the titlebar, taskbar and alt-tab switcher.
|
||||||
///
|
///
|
||||||
|
|
@ -83,45 +82,6 @@ fn main() {
|
||||||
};
|
};
|
||||||
let initial_query = seed_query();
|
let initial_query = seed_query();
|
||||||
|
|
||||||
// After the CLI early-exit above, deliberately: `quicksearch <query>` only
|
|
||||||
// reads, and must keep working from a terminal while the window is open.
|
|
||||||
// Two *windows* on one index are the problem — two indexers writing, and,
|
|
||||||
// once either has cancelled the other's SQLite locks, an attach that
|
|
||||||
// truncates the wal-index under a live mapping.
|
|
||||||
//
|
|
||||||
// Held for the life of the process in `platform`'s own slot, so the
|
|
||||||
// settings handler can move it when `database_path` changes; the kernel
|
|
||||||
// releases it on exit, however that exit happens.
|
|
||||||
match IndexLock::hold(&config.resolved_database_path()) {
|
|
||||||
Ok(()) => {}
|
|
||||||
Err(LockError::Held { pid }) => {
|
|
||||||
let who = match pid {
|
|
||||||
Some(pid) => format!(" (process {})", pid),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
|
||||||
let msg = format!(
|
|
||||||
"QuickSearch is already running{}.\n\nOnly one window can use \
|
|
||||||
the index at a time. Switch to the running window, or close \
|
|
||||||
it and try again.",
|
|
||||||
who
|
|
||||||
);
|
|
||||||
eprintln!("{}", msg);
|
|
||||||
// The app is normally launched from a desktop icon or a hotkey,
|
|
||||||
// where nothing is watching stderr.
|
|
||||||
rfd::MessageDialog::new()
|
|
||||||
.set_level(rfd::MessageLevel::Info)
|
|
||||||
.set_title("QuickSearch")
|
|
||||||
.set_description(&msg)
|
|
||||||
.show();
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
|
||||||
// Not "the lock is taken" — the filesystem could not answer. A
|
|
||||||
// convenience guard is never a good enough reason to refuse to open.
|
|
||||||
Err(LockError::Unsupported(why)) => {
|
|
||||||
eprintln!("warning: cannot lock the index ({}); starting anyway", why);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// With protection on, try the keychain before the window opens; a
|
// With protection on, try the keychain before the window opens; a
|
||||||
// verified key means no prompt at all. `None` starts locked, and no
|
// verified key means no prompt at all. `None` starts locked, and no
|
||||||
// index is touched until unlocked.
|
// index is touched until unlocked.
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue