Fix a flakey test that fails in CI, pruned unused SQLITE table entries from old work. Trimmed binary size by removing unneeded emoji and other EGUI items. LTO performance improvements.
Some checks failed
CI / linux (push) Failing after 12m6s
CI / windows-cross (push) Successful in 7m19s
CI / release (push) Has been skipped

This commit is contained in:
= 2026-08-17 22:30:43 -04:00
parent 9760503a3f
commit 208cc900fc
57 changed files with 1155 additions and 858 deletions

155
Cargo.lock generated
View file

@ -1112,7 +1112,6 @@ dependencies = [
"egui",
"enum-map",
"log",
"mime_guess2",
"profiling",
]
@ -1222,7 +1221,6 @@ dependencies = [
"bytemuck",
"ecolor",
"emath",
"epaint_default_fonts",
"log",
"nohash-hasher",
"parking_lot",
@ -1230,12 +1228,6 @@ dependencies = [
"serde",
]
[[package]]
name = "epaint_default_fonts"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2"
[[package]]
name = "equivalent"
version = "1.0.1"
@ -2078,15 +2070,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kamadak-exif"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef4fc70d0ab7e5b6bafa30216a6b48705ea964cdfc29c050f2412295eba58077"
dependencies = [
"mutate_once",
]
[[package]]
name = "keyboard-types"
version = "0.7.0"
@ -2337,18 +2320,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "mime_guess2"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
dependencies = [
"mime",
"phf",
"phf_shared",
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@ -2381,12 +2352,6 @@ dependencies = [
"pxfm",
]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]]
name = "naga"
version = "25.0.1"
@ -2975,50 +2940,6 @@ version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand 0.8.5",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
"syn 2.0.66",
"unicase",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
"unicase",
]
[[package]]
name = "pin-project"
version = "1.1.10"
@ -3138,7 +3059,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit 0.25.13+spec-1.1.0",
"toml_edit",
]
[[package]]
@ -3199,7 +3120,6 @@ dependencies = [
"getrandom 0.2.15",
"globset",
"infer",
"kamadak-exif",
"libc",
"lofty",
"memchr",
@ -3265,15 +3185,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.5"
@ -3621,11 +3532,11 @@ dependencies = [
[[package]]
name = "serde_spanned"
version = "0.6.6"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
dependencies = [
"serde",
"serde_core",
]
[[package]]
@ -3676,12 +3587,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "slab"
version = "0.4.9"
@ -3986,23 +3891,17 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "toml"
version = "0.8.2"
version = "1.1.3+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
dependencies = [
"serde",
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]]
name = "toml_datetime"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b"
dependencies = [
"serde",
"toml_datetime",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
]
[[package]]
@ -4014,19 +3913,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.25.13+spec-1.1.0"
@ -4034,7 +3920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
dependencies = [
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_datetime",
"toml_parser",
"winnow 1.0.4",
]
@ -4048,6 +3934,12 @@ dependencies = [
"winnow 1.0.4",
]
[[package]]
name = "toml_writer"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
[[package]]
name = "tracing"
version = "0.1.40"
@ -5008,15 +4900,6 @@ dependencies = [
"xkbcommon-dl",
]
[[package]]
name = "winnow"
version = "0.5.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
version = "0.7.15"

View file

@ -5,28 +5,91 @@ members = [
"crates/quicksearch-gui",
]
# No `[profile.release]` on purpose. Cross-crate inlining looks like it should
# pay here — the cascade calls into `snippet`/`query`, both into `memchr` and
# `zstd`, everything into `rusqlite`'s FFI wrappers — so it was measured, and
# it does not. Against an incremental rebuild of the GUI after touching core
# (10.2 s at the defaults):
#
# lto=thin 67 s search cold 29.9 ms warm best 14.77 ms index cold 459 ms
# lto=thin,cgu=1 152 s (build cost alone ruled it out)
# lto=fat,cgu=1 200 s search cold 29.2 ms warm best 14.57 ms index cold 479 ms
# (defaults) 10 s search cold 30.0 ms warm best 14.92 ms index cold 478 ms
#
# Every runtime column moves 0-4% and index-cold is not even monotonic, so the
# gain is at the noise floor while the build is 6.6-19.5x longer — on a CI that
# builds three targets. Re-measure before concluding otherwise.
#
# `panic = "abort"` is separately unavailable: `extract/pdf.rs` runs
# `pdf-extract` inside `catch_unwind`, so aborting would turn a malformed PDF
# into a killed process instead of one skipped file.
[workspace.package]
version = "1.1.0"
edition = "2021"
license = "GPL-3.0-or-later"
authors = ["Jeremy <jeremy@karsttech.com>"]
repository = "https://code.karsttech.com/jeremy/quick_search.git"
# Fat LTO, one codegen unit. This reverses an earlier decision, so both
# measurements are kept: the old one was right about what it measured, and it
# measured the wrong axis on the wrong kind of build.
#
# WHAT WAS MEASURED BEFORE, against an *incremental* rebuild of the GUI after
# touching core (10.2 s at the defaults):
#
# lto=thin 67 s search cold 29.9 ms warm best 14.77 ms index cold 459 ms
# lto=thin,cgu=1 152 s (build cost alone ruled it out)
# lto=fat,cgu=1 200 s search cold 29.2 ms warm best 14.57 ms index cold 479 ms
# (defaults) 10 s search cold 30.0 ms warm best 14.92 ms index cold 478 ms
#
# It concluded that the runtime gain was at the noise floor against a 6.6-19.5x
# longer build. Two things were wrong with that as a decision:
#
# * It never weighed SIZE, which turns out to be where the effect is.
# * A 10 s incremental rebuild is the worst possible denominator for a
# link-time optimization, because the LTO link is nearly the whole cost and
# there is no compile phase to amortize it against. CI never builds that
# way; it builds clean.
#
# RE-MEASURED CLEAN, on 6 cores, stripped `target/*/quicksearch`:
#
# stripped vs def search cold warm best index cold
# (defaults) 28,248,272 - 28.2 ms 14.7 ms 445 ms
# cgu=1 25,909,616 -8.3% 28.6 ms 14.8 ms 452 ms
# lto=thin 28,233,296 -0.1% (not run: no size effect)
# lto=thin,cgu=1 25,991,536 -8.0% (not run: worse than cgu=1 alone)
# lto=fat,cgu=1 24,284,912 -14.0% 27.3 ms 14.1 ms 443 ms
#
# Search is best of three runs; the spreads overlap, so read fat as "3-4%
# faster or even", never as a regression. It is both the fastest and by far the
# smallest, which is why it wins outright.
#
# Two surprises worth keeping. `codegen-units = 1` ALONE is worth 2.3 MB - the
# old table never isolated it, because every cgu=1 row there also carried LTO.
# And `lto = "thin"` alone is worth nothing at all (0.1%), while thin+cgu=1 is
# slightly *worse* than cgu=1 by itself. Thin is not a cheaper fat here; it is
# a different, useless thing.
#
# BUILD COST, clean, 6 cores. The second column is what CI actually pays on top
# of the binaries, since `cargo test --release --workspace` links every test,
# example and bin target - `--release` is `--profile=release`, so this profile
# is theirs too:
#
# bins + all test targets total
# (defaults) 143 s 28 s 171 s
# cgu=1 158 s 41 s 199 s
# lto=fat,cgu=1 198 s 100 s 298 s
#
# So +127 s on a clean CI run, not the tens of minutes the incremental figure
# above implies. Test targets link cheaply because they are small; it is the
# 320-crate dependency compile that dominates, and cgu=1 spreads across cores.
#
# WHAT IS DELIBERATELY NOT SET:
#
# `strip` would save nothing shipped. The packaging scripts and ci.yml strip the
# staged *copy*, which keeps `target/release/` symbolised for `perf` and for the
# RUST_BACKTRACE=1 release-test backtraces CI prints, and keeps `--no-strip` on
# build-deb.sh and build-appimage.sh meaning something. Setting it here would
# move the same 6.5 MB saving to a place where it costs debuggability.
#
# `panic = "abort"` is the largest win left - it would delete `.gcc_except_table`
# (428 KB after LTO) and most of `.eh_frame` (1.33 MB) - and is unavailable for
# two independent reasons. `extract/pdf.rs` runs `pdf-extract` inside
# `catch_unwind`, so aborting would turn a malformed PDF into a killed process
# instead of one skipped file. And cargo forces every dependency to rebuild with
# unwind when building tests under an abort profile, so `cargo test --release`
# would compile the whole graph a second time.
#
# Non-PIE would remove most of `.rela.dyn` (1.37 MB) and is rejected on
# hardening grounds: this program parses arbitrary user PDFs with a crate known
# to panic on malformed input, which is the last place to give up ASLR.
#
# The vendored OpenSSL (~1.3 MB of `.text`, via SQLCipher) is unreachable by any
# profile knob - `openssl-src` pins its own `-O2` - and dropping it would add a
# runtime `libcrypto` dependency, which is exactly what the AppImage's
# bundles-no-libraries invariant forbids.
[profile.release]
lto = "fat"
codegen-units = 1

View file

@ -492,11 +492,15 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
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
throughout, so left alone the log grows for the whole run. `files` holds
metadata (name, path, size, mtime, hash, MIME/type bitmask, per-row
index state); `searchabletext` is a *contentless* FTS5 table (postings
only, configurable tokenizer, trigram by default); canonical extracted
metadata (name, path, size, mtime, hash, MIME/type bitmask, content
state); `searchabletext` is a *contentless* FTS5 table over one column,
the document body (postings only, configurable tokenizer, trigram by
default) — filename ranks come from scanning `files.name`, so a `name`
column there would only index the same strings twice; canonical extracted
text lives zstd-compressed in `documents_text`, which powers snippets,
occurrence ranking, and fuzzy full-text search. Schema changes wipe and
occurrence ranking, and fuzzy full-text search, and whose uncompressed
length is read back from the zstd frame header rather than stored beside
it. Schema changes wipe and
rebuild by policy; the indexer (`open_or_recreate`) is the only code
allowed to do that; every consumer uses `open_existing`, which treats
drift as an error, never data loss. With password protection on, every
@ -523,11 +527,14 @@ Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime.
classify files by mtime into insert/update/skip, batch-write metadata,
sweep stale rows, then extract content (plaintext, RTF, Office — both the
OOXML/ODF zip formats and the pre-2007 binary `.doc`/`.xls`/`.ppt`, whose
OLE2 streams are read in `extract/ole.rs` — PDF, audio tags, EXIF; see
`extract/`) for FTS. PDFs are parsed once, with the text and the `Info`
dictionary taken off the same document: the two-parse version that preceded
it was the largest single memory consumer of a run over a PDF-heavy tree, and
it was what pulled a second copy of `lopdf` — and with it rayon's
OLE2 streams are read in `extract/ole.rs` — PDF, audio tags; see
`extract/`) for FTS. Images are claimed by no extractor: the EXIF reader
produced structured properties and never text, and with properties parked
(see `extract::ExtractedContent`) leaving `image/*` unclaimed is what keeps
the content pass from opening every image on disk. PDFs are parsed once:
the two-parse version that preceded it was the largest single memory
consumer of a run over a PDF-heavy tree, and it was what pulled a second
copy of `lopdf` — and with it rayon's
never-torn-down thread pool — into the build. That is a claim about PDFs
rather than about runs in general, and it is worth knowing which tree a
number came from: on one with almost no PDFs, a cold run peaks at 130 MiB

View file

@ -109,6 +109,12 @@ maximum_text_size = 262144
maximum_text_file_size = 2097152
# Files per batch during walks / inserts / extraction.
batch_size = 500
# Writer time one indexing root's turn may take before the round-robin moves
# 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
# large documents cannot leave another root's walkers parked behind it. 0
# gives each turn one batch_size quantum and no more.
writer_turn_slice_ms = 100
# Files per transaction for incremental FTS updates.
fts_update_batch_size = 1000
# How large the write-ahead log (index.sqlite-wal) may grow during an

View file

@ -35,7 +35,12 @@ quick-xml = "0.31"
# nothing new.
cfb = "0.7"
serde = { version = "1.0", features = ["derive"] }
toml = "0.8"
# 1.x, not 0.8: 0.8 pulls `toml_edit`, the whole format-preserving document
# model, for what is only `from_str` and `to_string_pretty` here. 1.x parses and
# writes as a stream instead - no `toml_edit`, no `winnow` 0.5 alongside the 1.0
# already in the tree - which is 252 KB of `.text` this crate was paying for a
# document API it never touches.
toml = "1"
mime_guess = "2.0"
infer = "0.15"
# Charset decoding for non-UTF-8 text (UTF-16 .reg exports, legacy
@ -56,7 +61,8 @@ rtf-parser = { version = "0.4", default-features = false }
# global thread pool is never torn down), chrono, time, md5 and a second nom.
pdf-extract = "0.12"
lofty = "0.19"
kamadak-exif = "0.5"
# Parked with `extract::image` — see `extract::ExtractedContent`.
# kamadak-exif = "0.5"
notify = "6.1"
ctrlc = "3.4"
zstd = "0.13"

View file

@ -77,7 +77,6 @@ pub struct FailedEntry {
pub struct SizeReport {
pub file_size_bytes: u64,
pub files_row_count: i64,
pub properties_row_count: i64,
pub failed_files_row_count: i64,
pub searchabletext_row_count: i64,
pub documents_text_row_count: i64,
@ -98,13 +97,20 @@ impl SizeReport {
/// Query the per-file indexing status. Returns `FileStatus` with
/// `basic == NotIndexed` if the path isn't in the database.
///
/// There is no stored basic state: a `files` row exists only once its
/// metadata has been read, so the row *is* the basic-indexed state. The
/// 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> {
let conn = open_existing(db_path, false)?;
let row: Option<(i64, i64, Option<String>)> = conn
let row: Option<(i64, Option<String>)> = conn
.query_row(
"SELECT basic_state, content_state, failure_msg FROM files WHERE path = ?1",
"SELECT f.content_state, ff.reason \
FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.path = ?1",
params![path],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|r| Ok((r.get(0)?, r.get(1)?)),
)
.optional()
.map_err(|e| format!("status_for_path({}): {}", path, e))?;
@ -115,9 +121,9 @@ pub fn status_for_path(db_path: &str, path: &str) -> Result<FileStatus, String>
content: IndexState::NotIndexed,
failure_reason: None,
},
Some((basic, content, reason)) => FileStatus {
Some((content, reason)) => FileStatus {
path: path.to_string(),
basic: IndexState::from(basic),
basic: IndexState::Done,
content: IndexState::from(content),
failure_reason: reason,
},
@ -164,18 +170,29 @@ pub fn index_size_breakdown(db_path: &str) -> Result<SizeReport, String> {
.map_err(|e| format!("count {}: {}", table, e))
};
let dt_row_count: i64 = count("documents_text")?;
let (dt_raw, dt_compressed): (i64, i64) = conn
.query_row(
"SELECT COALESCE(SUM(text_len), 0), COALESCE(SUM(LENGTH(text_zstd)), 0) FROM documents_text",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
// The uncompressed length is not a column: zstd records it in each frame's
// header, so this reads it back (see `repo::raw_text_len`). Only the
// header is wanted, and 18 bytes is the most one can occupy — projecting
// the prefix keeps this off the document bodies themselves.
let mut stmt = conn
.prepare("SELECT substr(text_zstd, 1, 18), LENGTH(text_zstd) FROM documents_text")
.map_err(|e| format!("documents_text size sum prepare: {}", e))?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, Vec<u8>>(0)?, r.get::<_, i64>(1)?)))
.map_err(|e| format!("documents_text size sum: {}", e))?;
let (mut dt_raw, mut dt_compressed) = (0i64, 0i64);
for row in rows {
let (header, compressed) = row.map_err(|e| format!("documents_text size row: {}", e))?;
// A frame with no recorded content size contributes nothing rather
// than skewing the ratio with a guess.
dt_raw += crate::db::repo::raw_text_len(&header).unwrap_or(0) as i64;
dt_compressed += compressed;
}
drop(stmt);
Ok(SizeReport {
file_size_bytes,
files_row_count: count("files")?,
properties_row_count: count("properties")?,
failed_files_row_count: count("failed_files")?,
searchabletext_row_count: count("searchabletext")?,
documents_text_row_count: dt_row_count,
@ -203,7 +220,7 @@ pub fn pending_content_count(db_path: &str) -> Result<i64, String> {
}
/// Remove a single file from the index. Returns whether a row was deleted.
/// Keeps FTS/documents/properties in sync via the repo helpers.
/// Keeps FTS and `documents_text` in sync via the repo helpers.
pub fn clear_path(db_path: &str, path: &str) -> Result<bool, String> {
let mut conn = open_existing(db_path, true)?;
let tx = conn
@ -239,8 +256,6 @@ mod tests {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -249,7 +264,7 @@ mod tests {
)
.unwrap()
.expect("unique path");
set_content_done(&tx, a, "a.txt", "hello", &[], zstd_of("hello").as_deref()).unwrap();
set_content_done(&tx, a, "hello", zstd_of("hello").as_deref()).unwrap();
let b = insert_file(
&tx,
&NewFile {
@ -258,8 +273,6 @@ mod tests {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
@ -334,8 +347,6 @@ mod tests {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -354,8 +365,6 @@ mod tests {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
@ -417,8 +426,6 @@ mod tests {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -428,7 +435,7 @@ mod tests {
.unwrap()
.expect("unique path");
let prose = "the quick brown fox jumps over the lazy dog. ".repeat(500);
set_content_done(&tx, id, "big.txt", &prose, &[], zstd_of(&prose).as_deref()).unwrap();
set_content_done(&tx, id, &prose, zstd_of(&prose).as_deref()).unwrap();
tx.commit().unwrap();
}
drop(conn);
@ -479,8 +486,6 @@ mod tests {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,

View file

@ -31,8 +31,8 @@ pub struct IndexWork {
pub drop_aliases: bool,
/// The `content_extensions` filter changed. Kept rows are re-tested
/// against it in both directions: newly-included files go back to
/// pending, newly-excluded ones give up their text, properties and FTS
/// row but keep the name/path row that filename search needs.
/// pending, newly-excluded ones give up their text and FTS row but keep
/// the name/path row that filename search needs.
pub reconcile_content: bool,
/// `store_text_for_snippets` turned on. Rows that finished extraction
/// under the old setting kept no text, so they must run again.

View file

@ -116,6 +116,22 @@ pub struct ProcessingConfig {
pub maximum_text_size: usize,
pub maximum_text_file_size: u64,
pub batch_size: usize,
/// Writer time one root's turn may take before the round moves on, in
/// milliseconds. The time half of the round-robin whose row half is
/// `batch_size`, and so the bound on how long any one root can hold up
/// the others.
///
/// Before there was one, an extraction turn ran to the end of whatever
/// was ready — half a second to two seconds of FTS5 trigram tokenization
/// for a batch of large documents — while a walking root's rows sat in
/// its channel and its walkers parked behind them. Reads as "4/4 workers
/// busy, no progress".
///
/// `0` gives each turn one `batch_size` quantum and no more, which is
/// the finest the round-robin goes; the tests that count work per round
/// use small values here so a phase cannot begin and end between two
/// status snapshots.
pub writer_turn_slice_ms: u64,
pub fts_update_batch_size: usize,
/// How large the write-ahead log may grow during a run before the indexer
/// forces a checkpoint, in bytes. `0` disables forced checkpoints;
@ -258,6 +274,7 @@ impl Default for ProcessingConfig {
maximum_text_size: 1024 * 256,
maximum_text_file_size: 1024 * 1024 * 2,
batch_size: 500,
writer_turn_slice_ms: 100,
fts_update_batch_size: 1000,
maximum_wal_size: 1024 * 1024 * 512,
tokenize: "trigram".to_string(),

View file

@ -400,8 +400,6 @@ mod tests {
parent: d.to_str().unwrap(),
size: std::fs::metadata(&f).unwrap().len(),
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,

View file

@ -25,7 +25,7 @@ pub const KEY_MISMATCH_PREFIX: &str = "KEY_MISMATCH: ";
/// values go stale: `files.mime`, `files.type` and `content_state` are
/// computed at walk time and never re-derived for unchanged files, so a
/// classification change needs the wipe to apply everywhere.
pub const CURRENT_SCHEMA_VERSION: u32 = 6;
pub const CURRENT_SCHEMA_VERSION: u32 = 7;
/// 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

View file

@ -102,7 +102,7 @@ fn legacy_layout_db_is_wiped_and_recreated() {
// New columns should exist (just prepare the SELECT — an
// unknown column name would parse-error here).
conn.query_row(
"SELECT basic_state, content_state, type, mime FROM files LIMIT 0",
"SELECT content_state, type, mime FROM files LIMIT 0",
[],
|_| Ok(()),
)
@ -172,8 +172,8 @@ fn open_existing_reads_nondefault_tokenizer_without_wiping() {
// Seed the FTS index (rowid = the files row we just inserted) so a
// MATCH query can be exercised against the on-disk tokenizer.
conn.execute(
"INSERT INTO searchabletext (rowid, name, text, properties) \
VALUES (last_insert_rowid(), 'note', 'hello world', '')",
"INSERT INTO searchabletext (rowid, text) \
VALUES (last_insert_rowid(), 'hello world')",
[],
)
.unwrap();

View file

@ -1,7 +1,7 @@
//! Row-level write helpers that keep the FTS5 contentless table in sync with
//! `files`/`documents`/`properties`.
//! `files`/`documents_text`.
//!
//! States (mirrors `basic_state` / `content_state` columns):
//! States (mirrors the `content_state` column):
//!
//! | value | meaning |
//! |------:|---------|
@ -45,7 +45,7 @@ fn set_state_clearing_failure(
) -> Result<(), String> {
exec(
tx,
"UPDATE files SET content_state = ?1, failure_msg = NULL WHERE id = ?2",
"UPDATE files SET content_state = ?1 WHERE id = ?2",
params![state, file_id],
|| format!("{} content_state {}", transition, file_id),
)?;
@ -66,8 +66,6 @@ pub struct NewFile<'a> {
pub parent: &'a str,
pub size: u64,
pub mtime: u64,
pub inode: Option<u64>,
pub device_id: Option<u64>,
pub mime: Option<&'a str>,
pub ftype: FileType,
pub hash: Option<&'a [u8]>,
@ -76,17 +74,16 @@ pub struct NewFile<'a> {
pub needs_content: bool,
}
/// Insert a new file row, returning its id. `basic_state` is set to DONE
/// (the row existing *is* the basic-index state); `content_state` comes from
/// `needs_content`. `INSERT OR IGNORE`: a UNIQUE(path) collision returns
/// `None` rather than aborting the batch.
/// Insert a new file row, returning its id. `content_state` comes from
/// `needs_content`; there is no separate basic state, because the row
/// existing *is* the basic-index state. `INSERT OR IGNORE`: a UNIQUE(path)
/// collision returns `None` rather than aborting the batch.
pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
let rows = tx
.prepare_cached(
"INSERT OR IGNORE INTO files (
name, path, parent, size, mtime, inode, device_id,
mime, type, basic_state, content_state, hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
name, path, parent, size, mtime, mime, type, content_state, hash
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.and_then(|mut stmt| {
stmt.execute(params![
@ -95,11 +92,8 @@ pub fn insert_file(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>,
f.parent,
f.size as i64,
f.mtime as i64,
f.inode.map(|x| x as i64),
f.device_id.map(|x| x as i64),
f.mime,
f.ftype.bits() as i64,
STATE_DONE,
initial_content_state(f),
f.hash,
])
@ -125,14 +119,14 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 {
/// Update a file's metadata in place (same path, changed size/mtime/hash) and
/// reset its content state from `f.needs_content`, clearing any extracted
/// content so the text-indexing pass re-processes it. Writes `size`, `mtime`,
/// `hash`, `mime`, `type`, `content_state` and `failure_msg` — and only
/// those; `name`, `parent`, `inode` and `device_id` are not refreshed here.
/// `hash`, `mime`, `type` and `content_state` — and only those; `name` and
/// `parent` are not refreshed here.
pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option<i64>, String> {
let id: Option<i64> = tx
.prepare_cached(
"UPDATE files
SET size = ?1, mtime = ?2, hash = ?3, mime = ?4, type = ?5,
content_state = ?6, failure_msg = NULL
content_state = ?6
WHERE path = ?7
RETURNING id",
)
@ -159,13 +153,11 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
Ok(Some(id))
}
/// Mark a file's content indexing as complete and write the extracted text +
/// properties atomically. The plaintext feeds the contentless FTS5 tokenizer;
/// when `store_text` is `true` it is also stored zstd-compressed in
/// Mark a file's content indexing as complete and write the extracted text
/// atomically. The plaintext feeds the contentless FTS5 tokenizer; when
/// `store_text` is `true` it is also stored zstd-compressed in
/// `documents_text` for snippet rendering (`false`: matches still work, but
/// result rows can't render snippets). `properties` are stored both as a
/// structured side-table (exact retrieval) and concatenated into the FTS
/// `properties` column (MATCH).
/// result rows can't render snippets).
/// `text_zstd` is the already-compressed body for the `documents_text`
/// sidecar, or `None` to write no sidecar at all (an empty body, or
/// `store_text_for_snippets` off).
@ -178,38 +170,27 @@ pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result<Option
pub fn set_content_done(
tx: &Transaction<'_>,
file_id: i64,
name: &str,
text: &str,
properties: &[(String, String)],
text_zstd: Option<&[u8]>,
) -> Result<(), String> {
remove_content_for_id(tx, file_id)?;
for (k, v) in properties {
exec(
tx,
"INSERT INTO properties(file_id, key, value) VALUES (?1, ?2, ?3)",
params![file_id, k, v],
|| format!("insert property {}={}", k, v),
)?;
}
let props_blob = encode_properties_for_fts(properties);
// Contentless FTS5 still accepts values on INSERT — the tokenizer needs
// them — it simply doesn't persist the raw column values.
exec(
tx,
"INSERT INTO searchabletext(rowid, name, text, properties) VALUES (?1, ?2, ?3, ?4)",
params![file_id, name, text, props_blob],
"INSERT INTO searchabletext(rowid, text) VALUES (?1, ?2)",
params![file_id, text],
|| format!("insert FTS row {}", file_id),
)?;
// No sidecar row for empty body text (e.g. an image whose extractor
// returned only EXIF properties) — the caller passes `None` for that.
// No sidecar row for empty body text (an audio file whose tags are all
// empty, say) — the caller passes `None` for that.
if let Some(compressed) = text_zstd {
exec(
tx,
"INSERT INTO documents_text(file_id, text_zstd, text_len) VALUES (?1, ?2, ?3)",
params![file_id, compressed, text.len() as i64],
"INSERT INTO documents_text(file_id, text_zstd) VALUES (?1, ?2)",
params![file_id, compressed],
|| format!("insert documents_text {}", file_id),
)?;
}
@ -261,17 +242,18 @@ impl DocDecoder {
// `decompress_to_buffer` writes into spare capacity and fails rather
// than growing, so the room has to be there first.
//
// The frame header would say how much is needed, but the indexer
// writes with `zstd::encode_all`, which is *stream*-based and so
// records no content size — `get_frame_content_size` says `None` for
// every row this ever sees. Falling back to `zstd::decode_all` there
// looked harmless and was not: it builds a streaming decoder per call,
// which measured as one ~2.4 MiB allocation per document and 27 of the
// 30 GiB a fuzzy search moved through the allocator.
// [`DocEncoder`] compresses through `ZSTD_compress2`, which knows the
// whole input up front and records its length in the frame header, so
// this reservation is normally exact and the loop below runs once.
//
// So grow this buffer instead and keep reusing it. It settles at the
// largest document in the scan within the first few rows, after which
// decoding a row allocates nothing at all.
// The loop is still the fallback, and it is not optional: a frame
// written by a *stream*-based encoder carries no content size, and
// falling back to `zstd::decode_all` for those looked harmless and was
// not — it builds a streaming decoder per call, which measured as one
// ~2.4 MiB allocation per document and 27 of the 30 GiB a fuzzy search
// moved through the allocator. Growing and reusing this buffer instead
// settles at the largest document in the scan within the first few
// rows, after which decoding a row allocates nothing at all.
if let Ok(Some(size)) = zstd::zstd_safe::get_frame_content_size(blob) {
self.buf.reserve(usize::try_from(size).ok()?);
}
@ -340,13 +322,31 @@ pub fn encode_one(text: &str, store_text: bool) -> Result<Option<Vec<u8>>, Strin
DocEncoder::new()?.encode(text).map(Some)
}
/// The uncompressed size of a stored `documents_text` blob, read out of the
/// zstd frame header instead of from a column beside it.
///
/// [`DocEncoder`] compresses through `ZSTD_compress2`, which is handed the
/// whole document at once and writes its length into the frame header. A
/// `text_len` column would have stored that same number a second time for
/// every row, to serve one figure in the size report.
///
/// `None` for a frame that carries no content size — nothing this writer
/// produces — or a corrupt one. The caller only needs the frame *header*, so
/// `blob` may be a prefix of the stored value.
pub fn raw_text_len(blob: &[u8]) -> Option<u64> {
zstd::zstd_safe::get_frame_content_size(blob).ok().flatten()
}
/// Mark a file's content extraction as failed. Keeps the basic row in place.
///
/// The reason is written once, to `failed_files` — which also carries the
/// timestamp, and which `list-failed` and `status` both read.
pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> Result<(), String> {
let now = crate::log::now_unix() as i64;
exec(
tx,
"UPDATE files SET content_state = ?1, failure_msg = ?2 WHERE id = ?3",
params![STATE_FAILED, reason, file_id],
"UPDATE files SET content_state = ?1 WHERE id = ?2",
params![STATE_FAILED, file_id],
|| format!("update content_state FAILED {}", file_id),
)?;
exec(
@ -448,10 +448,9 @@ pub fn delete_outside_ranges(
/// keyed to `files.id` first, then `files` itself. Not left to `ON DELETE
/// CASCADE`: `searchabletext` is an FTS5 virtual table with no foreign key at
/// all, and cascade only fires on connections with `PRAGMA foreign_keys` on.
const DEPENDENT_TABLES: [(&str, &str); 4] = [
const DEPENDENT_TABLES: [(&str, &str); 3] = [
("searchabletext", "rowid"),
("documents_text", "file_id"),
("properties", "file_id"),
("failed_files", "file_id"),
];
@ -711,16 +710,15 @@ pub fn paths_in_dir(conn: &Connection, parent: &str) -> Result<Vec<String>, Stri
.map_err(|e| format!("read path under {}: {}", parent, e))
}
/// Remove the FTS row, compressed text blob, and any `properties` rows for
/// a given file id. Does not touch the `files` row itself. Idempotent — a
/// missing row is fine.
/// Remove the FTS row and the compressed text blob for a given file id. Does
/// not touch the `files` row itself. Idempotent — a missing row is fine.
pub fn remove_content_for_id(tx: &Transaction<'_>, file_id: i64) -> Result<(), String> {
// `contentless_delete=1` on the FTS5 table makes this work without
// re-supplying the old column values (it tombstones the rowid).
//
// Spelled out rather than built from a (table, key) table: this runs for
// every extracted document and every changed file, and `format!`ing three
// constant strings per call also handed `prepare_cached` three freshly
// every extracted document and every changed file, and `format!`ing two
// constant strings per call also handed `prepare_cached` two freshly
// allocated keys to hash.
for (what, sql) in [
(
@ -731,7 +729,6 @@ pub fn remove_content_for_id(tx: &Transaction<'_>, file_id: i64) -> Result<(), S
"documents_text",
"DELETE FROM documents_text WHERE file_id = ?1",
),
("properties", "DELETE FROM properties WHERE file_id = ?1"),
] {
exec(tx, sql, params![file_id], || {
format!("delete {} for {}", what, file_id)
@ -740,16 +737,6 @@ pub fn remove_content_for_id(tx: &Transaction<'_>, file_id: i64) -> Result<(), S
Ok(())
}
/// Serialize properties for the FTS `properties` column. `key:value` pairs
/// separated by spaces so `MATCH 'properties:artist:beatles'` works.
fn encode_properties_for_fts(props: &[(String, String)]) -> String {
props
.iter()
.map(|(k, v)| format!("{}:{}", k, v))
.collect::<Vec<_>>()
.join(" ")
}
/// Free pages, as a percentage of the file, that make a [`maintain`] VACUUM
/// worth its cost: rewriting a multi-gigabyte index to reclaim a few
/// megabytes is minutes of I/O for no gain.

View file

@ -9,6 +9,34 @@ fn tmp_path() -> std::path::PathBuf {
crate::testutil::scratch_dir("repo").join("index.sqlite")
}
/// 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
/// [`DocEncoder`] compresses through `ZSTD_compress2` — the API that is
/// handed the whole input up front and records its length. A switch back to
/// a streaming encoder would silently zero that figure, so pin it here.
#[test]
fn a_compressed_body_carries_its_uncompressed_length() {
let mut enc = DocEncoder::new().unwrap();
let long = "lorem ipsum dolor sit amet ".repeat(4096);
for text in ["", "hello world", &long] {
let blob = enc.encode(text).unwrap();
assert_eq!(
raw_text_len(&blob),
Some(text.len() as u64),
"frame header lost the content size for a {}-byte body",
text.len()
);
// The size report projects only a prefix, never the whole body — the
// header fits in 18 bytes and that has to be enough.
let prefix = &blob[..blob.len().min(18)];
assert_eq!(
raw_text_len(prefix),
Some(text.len() as u64),
"the first 18 bytes must be enough to read the length"
);
}
}
#[test]
fn insert_update_delete_round_trip() {
let p = tmp_path();
@ -23,8 +51,6 @@ fn insert_update_delete_round_trip() {
parent: "/tmp",
size: 42,
mtime: 1_700_000_000,
inode: Some(7),
device_id: Some(64768),
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: Some(&[1, 2, 3]),
@ -33,15 +59,7 @@ fn insert_update_delete_round_trip() {
)
.unwrap()
.expect("unique path");
set_content_done(
&tx,
id,
"a.txt",
"hello world",
&[("title".to_string(), "hi".to_string())],
zstd_of("hello world").as_deref(),
)
.unwrap();
set_content_done(&tx, id, "hello world", zstd_of("hello world").as_deref()).unwrap();
tx.commit().unwrap();
}
@ -87,8 +105,6 @@ fn insert_writes_content_state_from_needs_content() {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -127,8 +143,6 @@ fn update_writes_content_state_from_needs_content() {
parent: "/tmp",
size: 10,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
@ -137,15 +151,7 @@ fn update_writes_content_state_from_needs_content() {
let id = {
let tx = conn.transaction().unwrap();
let id = insert_file(&tx, &row).unwrap().expect("unique path");
set_content_done(
&tx,
id,
"a.txt",
"old text",
&[],
zstd_of("old text").as_deref(),
)
.unwrap();
set_content_done(&tx, id, "old text", zstd_of("old text").as_deref()).unwrap();
tx.commit().unwrap();
id
};
@ -172,14 +178,6 @@ fn update_writes_content_state_from_needs_content() {
assert_eq!(got, Some(id));
tx.commit().unwrap();
}
let basic: i64 = conn
.query_row(
"SELECT basic_state FROM files WHERE id = ?1",
params![id],
|r| r.get(0),
)
.unwrap();
assert_eq!(basic, STATE_DONE);
assert_eq!(content_state(&conn), STATE_PENDING);
let fts_hits: i64 = conn
@ -222,8 +220,6 @@ fn insert_file_twice_on_same_path_is_idempotent() {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -264,8 +260,6 @@ fn delete_subtree_clears_every_dependent_table() {
parent,
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -274,15 +268,7 @@ fn delete_subtree_clears_every_dependent_table() {
)
.unwrap()
.expect("unique path");
set_content_done(
tx,
id,
name,
"body text",
&[("k".into(), "v".into())],
zstd_of("body text").as_deref(),
)
.unwrap();
set_content_done(tx, id, "body text", zstd_of("body text").as_deref()).unwrap();
id
};
@ -312,7 +298,6 @@ fn delete_subtree_clears_every_dependent_table() {
assert_eq!(count("SELECT COUNT(*) FROM files"), 2, "siblings survive");
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 2);
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 2);
assert_eq!(count("SELECT COUNT(*) FROM properties"), 2);
assert_eq!(
count("SELECT COUNT(*) FROM failed_files"),
0,
@ -352,8 +337,6 @@ fn seeded(conn: &mut Connection, paths: &[&str]) -> std::collections::HashMap<St
parent,
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -362,15 +345,7 @@ fn seeded(conn: &mut Connection, paths: &[&str]) -> std::collections::HashMap<St
)
.unwrap()
.expect("unique path");
set_content_done(
&tx,
id,
name,
"body text",
&[("k".into(), "v".into())],
zstd_of("body text").as_deref(),
)
.unwrap();
set_content_done(&tx, id, "body text", zstd_of("body text").as_deref()).unwrap();
ids.insert((*path).to_string(), id);
}
tx.commit().unwrap();
@ -487,7 +462,6 @@ fn delete_ids_clears_every_dependent_table() {
assert_eq!(count("SELECT COUNT(*) FROM files"), 2);
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 2);
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 2);
assert_eq!(count("SELECT COUNT(*) FROM properties"), 2);
assert_eq!(count("SELECT COUNT(*) FROM failed_files"), 0);
// The FTS index really lost them, not just the `files` row: a
@ -563,7 +537,6 @@ fn drop_stored_text_keeps_the_file_searchable() {
2,
"both files still match on content"
);
assert_eq!(count("SELECT COUNT(*) FROM properties"), 2);
drop(conn);
std::fs::remove_file(&p).ok();
@ -590,22 +563,20 @@ fn reset_content_pending_clears_the_last_extraction() {
tx.commit().unwrap();
}
let row: (i64, i64, Option<String>) = conn
let row: (i64, i64) = conn
.query_row(
"SELECT content_state, mtime, failure_msg FROM files WHERE id = ?1",
"SELECT content_state, mtime FROM files WHERE id = ?1",
params![id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(row.0, STATE_PENDING);
assert_eq!(row.1, 1, "metadata untouched — the file did not change");
assert_eq!(row.2, None);
let count = |sql: &str| -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() };
assert_eq!(count("SELECT COUNT(*) FROM files"), 2, "rows stay");
assert_eq!(count("SELECT COUNT(*) FROM searchabletext"), 0);
assert_eq!(count("SELECT COUNT(*) FROM documents_text"), 0);
assert_eq!(count("SELECT COUNT(*) FROM properties"), 0);
assert_eq!(
count("SELECT COUNT(*) FROM failed_files"),
0,
@ -726,8 +697,6 @@ fn checkpoint_and_close_truncates_wal() {
parent: "/tmp",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
@ -766,8 +735,6 @@ fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
parent: "/tmp/bulk",
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -779,9 +746,7 @@ fn seed_rows(conn: &mut Connection, range: std::ops::Range<usize>) {
set_content_done(
&tx,
id,
&name,
&"lorem ipsum dolor sit amet ".repeat(64),
&[],
zstd_of(&"lorem ipsum dolor sit amet ".repeat(64)).as_deref(),
)
.unwrap();
@ -860,8 +825,6 @@ fn a_busy_reader_defeats_the_autocheckpoint_but_not_a_forced_one() {
parent: "/tmp/bare",
size: i as u64,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::TEXT,
hash: Some(&[0u8; 32]),
@ -1020,8 +983,6 @@ fn set_content_failed_writes_failed_table() {
parent: "/tmp",
size: 0,
mtime: 1,
inode: None,
device_id: None,
mime: None,
ftype: FileType::EMPTY,
hash: None,
@ -1068,8 +1029,6 @@ fn insert_at(tx: &Transaction<'_>, path: &str, needs_content: bool) -> i64 {
parent,
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -1101,15 +1060,7 @@ fn count_root_counts_the_fts_rows_it_says_it_does() {
// Two searchable, and one of each way a row can fail to be.
for path in ["/tree/a.txt", "/tree/b.txt"] {
let id = insert_at(&tx, path, true);
set_content_done(
&tx,
id,
"n",
"body text",
&[],
zstd_of("body text").as_deref(),
)
.unwrap();
set_content_done(&tx, id, "body text", zstd_of("body text").as_deref()).unwrap();
}
let failed = insert_at(&tx, "/tree/c.bin", true);
set_content_failed(&tx, failed, "bad parse").unwrap();
@ -1134,15 +1085,7 @@ fn count_root_counts_the_fts_rows_it_says_it_does() {
{
let tx = conn.transaction().unwrap();
let id = insert_at(&tx, "/elsewhere/f.txt", true);
set_content_done(
&tx,
id,
"n",
"body text",
&[],
zstd_of("body text").as_deref(),
)
.unwrap();
set_content_done(&tx, id, "body text", zstd_of("body text").as_deref()).unwrap();
tx.commit().unwrap();
}
assert_eq!(fts_rows(&conn), 3);

View file

@ -163,13 +163,9 @@ CREATE TABLE files (
parent TEXT NOT NULL,
size INTEGER NOT NULL,
mtime INTEGER NOT NULL,
inode INTEGER,
device_id INTEGER,
mime TEXT,
type INTEGER NOT NULL DEFAULT 0,
basic_state INTEGER NOT NULL DEFAULT 0, -- 0=pending 1=done 2=failed
content_state INTEGER NOT NULL DEFAULT 0, -- 0=pending 1=done 2=failed 3=n/a
failure_msg TEXT,
hash BLOB
);
@ -188,13 +184,6 @@ CREATE INDEX idx_files_mime ON files(mime);
CREATE INDEX idx_files_hash ON files(hash);
CREATE INDEX idx_files_content_pending ON files(id) WHERE content_state = 0;
CREATE TABLE properties (
file_id INTEGER NOT NULL REFERENCES files(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
PRIMARY KEY (file_id, key)
);
CREATE TABLE failed_files (
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
reason TEXT,
@ -204,11 +193,16 @@ CREATE TABLE failed_files (
-- Canonical extracted text for every successfully content-indexed file.
-- Compressed with zstd (see `crate::db::repo::set_content_done`). Only
-- written when the extractor produced text; absent rows mean "no body
-- text" (e.g. an image with only EXIF properties).
-- text" (e.g. an audio file whose tags are all empty). "Has a row here" is
-- therefore *not* the same as "content indexed" `files.content_state` is
-- the authority on that.
--
-- The uncompressed length is not stored: zstd records it in the frame
-- header, so `crate::db::repo::raw_text_len` reads it back for the one
-- caller (the size report) that wants it.
CREATE TABLE documents_text (
file_id INTEGER PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE,
text_zstd BLOB NOT NULL,
text_len INTEGER NOT NULL -- original byte length pre-compression
text_zstd BLOB NOT NULL
);
CREATE TABLE config_validation (
@ -224,11 +218,17 @@ CREATE TABLE config_validation (
/// rowid=?` without replaying the original row text, at the cost of a modest
/// tombstone bitmap. Built-in `snippet()` is unavailable in contentless
/// mode — snippets are rendered in Rust from `documents_text` instead.
///
/// **One column, deliberately.** Document bodies are the only thing anything
/// ever MATCHes: the cascade pins its query to the body
/// (`crate::search::cascade::passes`) and filename ranks come from scanning
/// `files.name`, which the trigram index of a `name` column here would only
/// duplicate — at (len 2) postings per file indexed.
pub fn fts_create_sql(tokenizer: &str) -> String {
let effective = effective_tokenizer(tokenizer);
format!(
"CREATE VIRTUAL TABLE searchabletext USING fts5(\
name, text, properties, \
text, \
tokenize='{}', \
content='', \
contentless_delete=1\

View file

@ -1,11 +1,11 @@
//! Audio tag extraction via [`lofty`]. Pulls title/artist/album/genre/year/
//! track/duration into properties; concatenates tag values into `text` so
//! full-text search works across them.
//! Audio tag extraction via [`lofty`]. Concatenates the searchable tag
//! values — title, artist, album, genre, comment — into `text` so full-text
//! search works across them.
use std::path::Path;
use lofty::{
file::{AudioFile, TaggedFileExt},
file::TaggedFileExt,
probe::Probe,
tag::{Accessor, ItemKey},
};
@ -25,58 +25,42 @@ impl Extractor for AudioExtractor {
.read()
.map_err(|e| format!("lofty read {}: {}", path.display(), e))?;
let mut out = ExtractedContent::default();
let duration_secs = tagged.properties().duration().as_secs();
if duration_secs > 0 {
out.properties
.insert("duration".to_string(), duration_secs.to_string());
}
// properties (parked): year, track and duration went to the property
// map alone and never reached `text`, so nothing collects them now.
// See `super::ExtractedContent`.
let mut pieces: Vec<String> = Vec::new();
if let Some(tag) = tagged.primary_tag().or_else(|| tagged.first_tag()) {
for (key, item_key) in [
("title", ItemKey::TrackTitle),
("artist", ItemKey::TrackArtist),
("album", ItemKey::AlbumTitle),
("genre", ItemKey::Genre),
("year", ItemKey::Year),
("track", ItemKey::TrackNumber),
("comment", ItemKey::Comment),
] {
if let Some(v) = tag.get_string(&item_key) {
if !v.is_empty() {
out.properties.insert(key.to_string(), v.to_string());
}
}
}
// Accessor shortcuts for common fields if the ItemKey lookup missed.
if !out.properties.contains_key("title") {
if let Some(t) = tag.title() {
out.properties.insert("title".to_string(), t.to_string());
}
}
if !out.properties.contains_key("artist") {
if let Some(a) = tag.artist() {
out.properties.insert("artist".to_string(), a.to_string());
}
}
if !out.properties.contains_key("album") {
if let Some(a) = tag.album() {
out.properties.insert("album".to_string(), a.to_string());
}
let mut push = |value: Option<String>| {
if let Some(v) = value.filter(|v: &String| !v.is_empty()) {
pieces.push(v);
}
};
// `ItemKey` first, falling back to the `Accessor` shortcut for the
// three fields that have one — a tag can carry the value under
// either.
push(
tag.get_string(&ItemKey::TrackTitle)
.filter(|v| !v.is_empty())
.map(str::to_string)
.or_else(|| tag.title().map(|t| t.to_string())),
);
push(
tag.get_string(&ItemKey::TrackArtist)
.filter(|v| !v.is_empty())
.map(str::to_string)
.or_else(|| tag.artist().map(|a| a.to_string())),
);
push(
tag.get_string(&ItemKey::AlbumTitle)
.filter(|v| !v.is_empty())
.map(str::to_string)
.or_else(|| tag.album().map(|a| a.to_string())),
);
push(tag.get_string(&ItemKey::Genre).map(str::to_string));
push(tag.get_string(&ItemKey::Comment).map(str::to_string));
}
// Join the searchable tag values into one blob so FTS hits on any of them.
let mut pieces: Vec<&str> = Vec::new();
for k in ["title", "artist", "album", "genre", "comment"] {
if let Some(v) = out.properties.get(k) {
pieces.push(v.as_str());
}
}
out.text = pieces.join(" ");
Ok(out)
Ok(ExtractedContent::with_text(pieces.join(" ")))
}
}
@ -84,6 +68,84 @@ impl Extractor for AudioExtractor {
mod tests {
use super::*;
/// A minimal but real MPEG file: an ID3v2.3 tag carrying `frames`
/// (`("TPE1", "…")` and friends), followed by one silent MPEG-1 Layer III
/// frame so the probe recognizes the format from its content.
fn write_mp3(tag: &str, frames: &[(&str, &str)]) -> std::path::PathBuf {
let mut body = Vec::new();
for (id, value) in frames {
let mut payload = vec![0x00]; // ISO-8859-1
payload.extend_from_slice(value.as_bytes());
body.extend_from_slice(id.as_bytes());
// ID3v2.3 frame sizes are plain big-endian, unlike the tag size.
body.extend_from_slice(&(payload.len() as u32).to_be_bytes());
body.extend_from_slice(&[0, 0]); // flags
body.extend_from_slice(&payload);
}
let mut out = Vec::new();
out.extend_from_slice(b"ID3");
out.extend_from_slice(&[0x03, 0x00, 0x00]); // v2.3, no flags
// Tag size is syncsafe: seven bits per byte.
let n = body.len() as u32;
out.extend_from_slice(&[
((n >> 21) & 0x7F) as u8,
((n >> 14) & 0x7F) as u8,
((n >> 7) & 0x7F) as u8,
(n & 0x7F) as u8,
]);
out.extend_from_slice(&body);
// MPEG-1 Layer III, 128 kbps, 44.1 kHz, no padding: 417-byte frames.
// Four of them, because the probe confirms a sync word by finding the
// next frame where the first one says it will be.
for _ in 0..4 {
out.extend_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
out.resize(out.len() + 413, 0);
}
let path = crate::testutil::scratch_dir(tag).join("track.mp3");
std::fs::write(&path, &out).expect("write fixture mp3");
path
}
/// The searchable text is assembled from the tag values, which is the
/// only reason audio files are full-text indexed at all. Pins the
/// rewrite that dropped the property map this used to be built from.
#[test]
fn tag_values_become_searchable_text() {
let path = write_mp3(
"audio-tags",
&[
("TIT2", "Blue Monday"),
("TPE1", "New Order"),
("TALB", "Power Corruption"),
("TCON", "Synthpop"),
],
);
let out = AudioExtractor.extract(&path).expect("extract");
for expected in ["Blue Monday", "New Order", "Power Corruption", "Synthpop"] {
assert!(
out.text.contains(expected),
"{:?} missing from {:?}",
expected,
out.text
);
}
// Title first, then artist, album, genre — a stable order so the
// stored text does not churn between runs.
assert_eq!(out.text, "Blue Monday New Order Power Corruption Synthpop");
}
/// No tags at all is a successful extraction with nothing to store, not
/// a failure — `set_content_done` writes no sidecar row for it.
#[test]
fn an_untagged_file_yields_empty_text() {
let path = write_mp3("audio-untagged", &[]);
let out = AudioExtractor.extract(&path).expect("extract");
assert!(out.text.is_empty(), "unexpected text {:?}", out.text);
}
#[test]
fn supports_audio_mimes() {
let e = AudioExtractor;

View file

@ -1,4 +1,4 @@
//! Content extractors: text plus structured properties (title, artist, EXIF, …).
//! Content extractors: the searchable text of a file.
//!
//! An [`Extractor`] decides whether it can handle a given MIME type and, if
//! so, produces [`ExtractedContent`] for the file. The [`Registry`] picks the
@ -9,50 +9,59 @@
//! is decided once, upstream in [`crate::mime::guess_mime_from_head`];
//! nothing downstream reopens the file to ask again.
use std::collections::HashMap;
use std::path::Path;
pub mod audio;
pub mod image;
// pub mod image; // parked — see `ExtractedContent` below
pub mod office;
pub mod ole;
pub mod pdf;
pub mod plaintext;
pub mod rtf;
/// Result of a successful extraction. `text` feeds the FTS5 `text` column;
/// `properties` feeds both the `properties` FTS5 column (as `key:value`
/// tokens) and the structured `properties` table for later retrieval.
/// Result of a successful extraction: `text` feeds the FTS5 `text` column.
///
/// Extractors may return an empty `text` when the file has no narrative
/// content (e.g. an image where only EXIF matters). Filename search still
/// works in that case.
/// content (an audio file whose tags are all empty, say). Filename search
/// still works in that case.
///
/// # Structured properties are parked
///
/// Extractors used to return a `properties: HashMap<String, String>` beside
/// the text — EXIF, audio tags, the PDF `Info` dictionary — stored in a
/// `properties` table *and* concatenated into a `properties` FTS column.
/// Nothing ever read either back: no query, no result row, no UI. So the
/// storage is gone and the extraction is commented out rather than deleted.
///
/// Reviving it means restoring, together: this field and
/// `properties_sorted`, the blocks marked "properties (parked)" in
/// `image.rs` / `audio.rs` / `pdf.rs`, the `image` module registration in
/// [`Registry::default_set`], the `properties` table and FTS column in
/// [`crate::db::schema`], the `properties` argument to
/// [`crate::db::repo::set_content_done`] — and a consumer that shows them.
#[derive(Debug, Default, Clone)]
pub struct ExtractedContent {
pub text: String,
pub properties: HashMap<String, String>,
// pub properties: HashMap<String, String>,
}
impl ExtractedContent {
pub fn with_text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
properties: HashMap::new(),
}
Self { text: text.into() }
}
/// Convert properties into the `Vec<(String, String)>` shape expected by
/// [`crate::db::repo::set_content_done`]. Keys are sorted for determinism
/// in tests and snapshots.
pub fn properties_sorted(&self) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = self
.properties
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
// /// Convert properties into the `Vec<(String, String)>` shape expected by
// /// [`crate::db::repo::set_content_done`]. Keys are sorted for determinism
// /// in tests and snapshots.
// pub fn properties_sorted(&self) -> Vec<(String, String)> {
// let mut v: Vec<(String, String)> = self
// .properties
// .iter()
// .map(|(k, v)| (k.clone(), v.clone()))
// .collect();
// v.sort_by(|a, b| a.0.cmp(&b.0));
// v
// }
}
/// Boxed error type for extractor failures. A string reason is stored on the
@ -152,15 +161,21 @@ impl Registry {
.and_then(|e| e.extract_from_head(path, head))
}
/// The default set: RTF, plaintext, office docs, PDF, audio tags,
/// image EXIF.
/// The default set: RTF, plaintext, office docs, PDF, audio tags.
///
/// Order matters — the first extractor whose `supports` accepts a MIME
/// wins. RTF precedes plaintext because plaintext claims every `text/*`
/// and would swallow `text/rtf` as raw control words. Plaintext
/// precedes audio and image because it deliberately claims playlist
/// precedes audio because it deliberately claims playlist
/// (`audio/x-mpegurl`, `audio/scpls`) and SVG MIMEs whose text is worth
/// more than their tags.
///
/// No image extractor: it produced EXIF properties and never any text,
/// so with properties parked it would open and parse every image on
/// disk to return nothing. Leaving `image/*` unclaimed is what makes
/// [`crate::file_handling::content_extractable`] record images as
/// `STATE_NA` at walk time, so the content pass never opens them.
/// Filenames are indexed exactly as before.
pub fn default_set() -> Self {
Self::new()
.with(rtf::RtfExtractor)
@ -168,7 +183,7 @@ impl Registry {
.with(office::OfficeExtractor)
.with(pdf::PdfExtractor)
.with(audio::AudioExtractor)
.with(image::ImageExtractor)
// .with(image::ImageExtractor) // parked with `ExtractedContent`
}
}
@ -205,6 +220,7 @@ mod tests {
assert!(r
.extract_complete_head(p, "application/pdf", b"%PDF-1.4")
.is_none());
// No extractor claims images at all now — the head path must agree.
assert!(r
.extract_complete_head(p, "image/png", b"\x89PNG")
.is_none());
@ -293,17 +309,13 @@ mod tests {
}
}
/// Images are claimed by nothing, so the walk records them `NA` and the
/// content pass never opens them. Pins the parked image extractor.
#[test]
fn properties_sorted_is_deterministic() {
let mut c = ExtractedContent::with_text("hi");
c.properties.insert("b".into(), "2".into());
c.properties.insert("a".into(), "1".into());
assert_eq!(
c.properties_sorted(),
vec![
("a".to_string(), "1".to_string()),
("b".to_string(), "2".to_string())
]
);
fn images_are_not_claimed_by_any_extractor() {
let r = Registry::default_set();
for mime in ["image/jpeg", "image/png", "Image/JPEG", "image/tiff"] {
assert!(!r.supports(mime), "{} should be unclaimed", mime);
}
}
}

View file

@ -1,9 +1,8 @@
//! PDF text extraction.
//!
//! One `Document::load` per file, then both the text and the `Info` dictionary
//! are taken off it. `pdf_extract` can panic or hard-error on malformed files;
//! any failure is surfaced to the caller and marks the file's content state as
//! failed.
//! One `Document::load` per file, then the text is taken off it.
//! `pdf_extract` can panic or hard-error on malformed files; any failure is
//! surfaced to the caller and marks the file's content state as failed.
//!
//! `lopdf` is reached through `pdf_extract`'s own `pub use lopdf::*` and must
//! **not** be declared in `Cargo.toml` again: a direct declaration resolved a
@ -16,7 +15,7 @@ use std::cell::Cell;
use std::path::Path;
use std::sync::OnceLock;
use pdf_extract::{Document, Object, PlainTextOutput};
use pdf_extract::{Document, PlainTextOutput};
use super::{ExtractError, ExtractedContent, Extractor};
@ -75,15 +74,18 @@ impl Extractor for PdfExtractor {
}
}
/// The six `Info` keys worth keeping, in the order they are written.
const INFO_KEYS: [&str; 6] = [
"Title", "Author", "Subject", "Keywords", "Creator", "Producer",
];
// properties (parked) — the `Info` dictionary read. See
// `super::ExtractedContent`; reviving this also needs the `Object` import
// above and `object_to_string` below.
//
// /// The six `Info` keys worth keeping, in the order they are written.
// const INFO_KEYS: [&str; 6] = [
// "Title", "Author", "Subject", "Keywords", "Creator", "Producer",
// ];
/// Load the document once; take the text and the `Info` dictionary off it.
/// This is `pdf_extract::extract_text`'s body (load, decrypt, `output_doc`)
/// spelled out so the `Info` read happens while the document is still in
/// scope.
/// Load the document once and take the text off it. This is
/// `pdf_extract::extract_text`'s body (load, decrypt, `output_doc`) spelled
/// out, which is also what kept the document in scope for the `Info` read.
fn extract_one_pass(path: &Path) -> Result<ExtractedContent, ExtractError> {
let mut doc = Document::load(path).map_err(|e| format!("pdf_extract: {}", e))?;
// Decryption must happen before either the content streams or the `Info`
@ -98,35 +100,35 @@ fn extract_one_pass(path: &Path) -> Result<ExtractedContent, ExtractError> {
let mut sink = PlainTextOutput::new(&mut text);
pdf_extract::output_doc(&doc, &mut sink).map_err(|e| format!("pdf_extract: {}", e))?;
}
let mut out = ExtractedContent::with_text(text);
// Soft-fail, unchanged: a document with no readable `Info` dictionary
// still has its text, and the text is the half that matters.
let info = doc
.trailer
.get(b"Info")
.ok()
.and_then(|o| o.as_reference().ok())
.and_then(|id| doc.get_object(id).ok())
.and_then(|o| o.as_dict().ok());
if let Some(dict) = info {
for key in INFO_KEYS {
if let Some(s) = dict.get(key.as_bytes()).ok().and_then(object_to_string) {
if !s.is_empty() {
out.properties.insert(key.to_ascii_lowercase(), s);
}
}
}
}
Ok(out)
}
fn object_to_string(obj: &Object) -> Option<String> {
match obj {
Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
_ => None,
}
// properties (parked): the `Info` dictionary was read here, soft-failing
// when absent because the text is the half that matters. It is all that
// held `doc` open past `output_doc`.
//
// let info = doc
// .trailer
// .get(b"Info")
// .ok()
// .and_then(|o| o.as_reference().ok())
// .and_then(|id| doc.get_object(id).ok())
// .and_then(|o| o.as_dict().ok());
// if let Some(dict) = info {
// for key in INFO_KEYS {
// if let Some(s) = dict.get(key.as_bytes()).ok().and_then(object_to_string) {
// if !s.is_empty() {
// out.properties.insert(key.to_ascii_lowercase(), s);
// }
// }
// }
// }
//
// fn object_to_string(obj: &Object) -> Option<String> {
// match obj {
// Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).into_owned()),
// Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).into_owned()),
// _ => None,
// }
// }
Ok(ExtractedContent::with_text(text))
}
#[cfg(test)]
@ -153,7 +155,7 @@ mod tests {
assert!(!PdfExtractor.supports("application/zip"));
}
use pdf_extract::{dictionary, Dictionary, Stream, StringFormat};
use pdf_extract::{dictionary, Dictionary, Object, Stream, StringFormat};
use std::path::PathBuf;
/// Write a one-page PDF drawing `body`, with `info` as its `Info`
@ -213,8 +215,10 @@ mod tests {
Object::String(s.as_bytes().to_vec(), StringFormat::Literal)
}
/// A document *with* an `Info` dictionary still extracts its text — the
/// dictionary is no longer read, and must not get in the way.
#[test]
fn extracts_text_and_info_properties() {
fn extracts_text_from_a_document_with_an_info_dictionary() {
let path = write_pdf(
"pdf-full",
"Hello QuickSearch",
@ -234,32 +238,6 @@ mod tests {
"drawn text missing from {:?}",
out.text
);
// Lowercased keys, which is the contract the rest of the pipeline
// stores under.
assert_eq!(
out.properties.get("title").map(String::as_str),
Some("The Title")
);
assert_eq!(
out.properties.get("author").map(String::as_str),
Some("An Author")
);
assert_eq!(
out.properties.get("subject").map(String::as_str),
Some("A Subject")
);
assert_eq!(
out.properties.get("keywords").map(String::as_str),
Some("alpha beta")
);
assert_eq!(
out.properties.get("creator").map(String::as_str),
Some("A Creator")
);
assert_eq!(
out.properties.get("producer").map(String::as_str),
Some("A Producer")
);
}
/// The soft-fail path: no `Info` dictionary is not an extraction failure,
@ -269,54 +247,6 @@ mod tests {
let path = write_pdf("pdf-noinfo", "Body Only", None);
let out = PdfExtractor.extract(&path).expect("extract");
assert!(out.text.contains("Body Only"));
assert!(
out.properties.is_empty(),
"unexpected properties: {:?}",
out.properties
);
}
/// An empty `Info` value is absence, not an empty property.
#[test]
fn empty_info_values_are_not_stored() {
let path = write_pdf(
"pdf-emptyinfo",
"Body",
Some(dictionary! {
"Title" => text_string(""),
"Author" => text_string("Real Author"),
}),
);
let out = PdfExtractor.extract(&path).expect("extract");
assert!(!out.properties.contains_key("title"), "empty title stored");
assert_eq!(
out.properties.get("author").map(String::as_str),
Some("Real Author")
);
}
/// `Info` values that are not strings or names are skipped rather than
/// rendered — pins `object_to_string`'s catch-all arm.
#[test]
fn non_string_info_values_are_skipped() {
let path = write_pdf(
"pdf-badinfo",
"Body",
Some(dictionary! {
"Producer" => 42,
"Title" => text_string("Kept"),
}),
);
let out = PdfExtractor.extract(&path).expect("extract");
assert!(
!out.properties.contains_key("producer"),
"integer Info value was rendered: {:?}",
out.properties
);
assert_eq!(
out.properties.get("title").map(String::as_str),
Some("Kept")
);
}
/// Malformed input must come back as an error, not take the process

View file

@ -145,7 +145,6 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(from_disk.text, from_head.text);
assert_eq!(from_disk.properties, from_head.properties);
std::fs::remove_file(&p).ok();
}

View file

@ -125,7 +125,7 @@ pub fn process_batch_updates(
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
let zstd = body_or_skip!(bodies, i, rec.path);
store_inline_text(&tx, id, rec, text, zstd)?;
repo::set_content_done(&tx, id, text, zstd)?;
}
}
@ -136,19 +136,6 @@ pub fn process_batch_updates(
Ok(())
}
/// Store text the walk already extracted, so the content pass skips this row.
/// Same [`repo::set_content_done`] the content pass calls, so a row finished
/// here is indistinguishable from one finished there.
pub(crate) fn store_inline_text(
tx: &rusqlite::Transaction<'_>,
file_id: i64,
rec: &OwnedNewFile,
text: &str,
text_zstd: Option<&[u8]>,
) -> Result<(), String> {
repo::set_content_done(tx, file_id, &rec.name, text, &[], text_zstd)
}
/// Write already-prepared records for newly discovered files. Silent, like
/// [`process_batch_updates`], and likewise stores any text the walk already
/// extracted.
@ -184,7 +171,7 @@ pub fn process_batch_inserts(
.map_err(|e| format!("Failed to insert file record: {}", e))?;
if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) {
let zstd = body_or_skip!(bodies, i, rec.path);
store_inline_text(&tx, id, rec, text, zstd)?;
repo::set_content_done(&tx, id, text, zstd)?;
}
}
@ -410,16 +397,12 @@ pub fn store_extracted(
done.consumed += 1;
match &bodies[i] {
Err(e) => crate::log_warn!("compress text for {}: {}", row.name, e),
Ok(zstd) => match store_content_outcome(
&tx,
row.file_id,
&row.name,
&row.outcome,
zstd.as_deref(),
) {
Ok(zstd) => {
match store_content_outcome(&tx, row.file_id, &row.outcome, zstd.as_deref()) {
Ok(()) => done.written += 1,
Err(e) => crate::log_warn!("content indexing for {}: {}", row.name, e),
},
}
}
}
if stop_flag.load(Ordering::Relaxed) || std::time::Instant::now() >= deadline {
cut = true;

View file

@ -11,12 +11,12 @@ mod count_and_extract_tests;
#[cfg(test)]
mod tests;
pub(crate) use batch::max_text_file_size;
pub use batch::{
cleanup_stale_index_entries, count_extract_scope, mark_oversize_pending_na,
process_batch_inserts, process_batch_updates, store_extracted, ExtractCursor, ExtractScope,
Stored,
};
pub(crate) use batch::{max_text_file_size, store_inline_text};
pub use counting::count_tree_entries_fast;
pub use paths::{db_key_for_missing_path, filtered_dirs, filtered_walk, UnreadableDirs};
pub(crate) use paths::{normalize_root_string, path_to_db_string, warn_if_unrepresentable};

View file

@ -8,20 +8,6 @@ use walkdir::{DirEntry, WalkDir};
use crate::config::IgnoreSet;
/// Derive (inode, device_id) from a `std::fs::Metadata` on platforms that
/// expose them. Returns `(None, None)` on Windows and other non-Unix targets.
pub(super) fn inode_and_device(_meta: &std::fs::Metadata) -> (Option<u64>, Option<u64>) {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
(Some(_meta.ino()), Some(_meta.dev()))
}
#[cfg(not(unix))]
{
(None, None)
}
}
/// Render a path as the string stored in `files.path`.
///
/// `Path::canonicalize` on Windows hands back extended-length paths; the

View file

@ -10,7 +10,7 @@ use std::time::UNIX_EPOCH;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use super::paths::{inode_and_device, parent_str};
use super::paths::parent_str;
use super::*;
use crate::config::Config;
use crate::db::repo::{self, NewFile};
@ -110,8 +110,6 @@ pub struct OwnedNewFile {
pub parent: String,
pub size: u64,
pub mtime: u64,
pub inode: Option<u64>,
pub device_id: Option<u64>,
pub mime: Option<String>,
pub ftype: FileType,
/// `None` only for a dehydrated cloud placeholder. Stored as SQL NULL,
@ -136,8 +134,6 @@ impl OwnedNewFile {
parent: &self.parent,
size: self.size,
mtime: self.mtime,
inode: self.inode,
device_id: self.device_id,
mime: self.mime.as_deref(),
ftype: self.ftype,
hash: self.hash.as_deref(),
@ -218,7 +214,6 @@ pub fn prepare_file_record(
.file_name()
.map(|n| n.to_string_lossy().into_owned())?;
let parent = parent_str(path);
let (inode, device_id) = inode_and_device(meta);
// Sniff from the bytes hashing already read; an empty head falls back to
// the extension.
let mime = guess_mime_from_head(Path::new(path), &head);
@ -258,8 +253,6 @@ pub fn prepare_file_record(
parent,
size,
mtime,
inode,
device_id,
mime,
ftype,
hash,
@ -284,10 +277,9 @@ pub fn prepare_file_record_from_path(
prepare_file_record(&db_path, &meta, config, registry)
}
/// Extract content for one file and record the outcome on its row: text +
/// properties on success, `NA` when no extractor applies or the
/// `content_extensions` filter excludes it, `FAILED` with a reason on
/// extractor errors. The single implementation behind the full text-index
/// Extract content for one file and record the outcome on its row: text on
/// success, `NA` when no extractor applies or the `content_extensions`
/// filter excludes it, `FAILED` with a reason on extractor errors. The single implementation behind the full text-index
/// pass and incremental updates.
///
/// `mime` is authoritative, including when it is `None`: the head was already
@ -295,7 +287,6 @@ pub fn prepare_file_record_from_path(
pub fn extract_and_store(
tx: &rusqlite::Transaction<'_>,
file_id: i64,
name: &str,
path: &str,
mime: Option<&str>,
registry: &Registry,
@ -306,18 +297,15 @@ pub fn extract_and_store(
Some(text) => repo::encode_one(text, config.processing.store_text_for_snippets)?,
None => None,
};
store_content_outcome(tx, file_id, name, &outcome, zstd.as_deref())
store_content_outcome(tx, file_id, &outcome, zstd.as_deref())
}
/// What should be written for one file's content, decided without touching
/// the database.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentOutcome {
/// Text (already truncated to `maximum_text_size`) and sorted properties.
Done {
text: String,
properties: Vec<(String, String)>,
},
/// Text, already truncated to `maximum_text_size`.
Done { text: String },
/// No extractor claims the MIME, or `content_extensions` excludes it.
NotApplicable,
/// The extractor ran and failed; the reason goes on the row.
@ -367,10 +355,7 @@ pub fn decide_content(
content.text =
safe_truncate_string(&content.text, config.processing.maximum_text_size);
}
ContentOutcome::Done {
properties: content.properties_sorted(),
text: content.text,
}
ContentOutcome::Done { text: content.text }
}
Ok(None) => ContentOutcome::NotApplicable,
Err(reason) => ContentOutcome::Failed(reason),
@ -385,14 +370,11 @@ pub fn decide_content(
pub fn store_content_outcome(
tx: &rusqlite::Transaction<'_>,
file_id: i64,
name: &str,
outcome: &ContentOutcome,
text_zstd: Option<&[u8]>,
) -> Result<(), String> {
match outcome {
ContentOutcome::Done { text, properties } => {
repo::set_content_done(tx, file_id, name, text, properties, text_zstd)
}
ContentOutcome::Done { text } => repo::set_content_done(tx, file_id, text, text_zstd),
ContentOutcome::NotApplicable => repo::set_content_na(tx, file_id),
ContentOutcome::Failed(reason) => repo::set_content_failed(tx, file_id, reason),
}
@ -402,7 +384,7 @@ pub fn store_content_outcome(
/// to its [`repo::DocEncoder`] ahead of the lock.
pub fn outcome_body(outcome: &ContentOutcome) -> Option<&str> {
match outcome {
ContentOutcome::Done { text, .. } => Some(text),
ContentOutcome::Done { text } => Some(text),
_ => None,
}
}

View file

@ -19,7 +19,7 @@ use crate::db::repo;
use crate::extract::Registry;
use crate::file_handling::{
db_key_for_missing_path, extract_and_store, filtered_walk, prepare_file_record_from_path,
store_inline_text, ExtractCursor, UnreadableDirs,
ExtractCursor, UnreadableDirs,
};
use crate::platform::path_has_hidden_component_under;
use crate::watcher::FsEvent;
@ -138,12 +138,11 @@ fn upsert_file(
} else if let Some(text) = rec.inline_text.as_deref() {
// `prepare_file_record_from_path` already read the whole file.
let zstd = repo::encode_one(text, config.processing.store_text_for_snippets)?;
store_inline_text(&tx, file_id, &rec, text, zstd.as_deref())?;
repo::set_content_done(&tx, file_id, text, zstd.as_deref())?;
} else {
extract_and_store(
&tx,
file_id,
&rec.name,
&rec.path,
rec.mime.as_deref(),
registry,

View file

@ -84,15 +84,6 @@ impl Drop for CancelOnDrop {
}
}
/// Writer time one root's turn may take before the round moves on.
///
/// This is the bound on how long any root can hold up the others. Before
/// there was one, an extraction turn ran to the end of whatever was ready —
/// half a second to two seconds of FTS5 trigram tokenization for a batch of
/// large documents — while a walking root's rows sat in its channel and its
/// walkers parked behind them. Reads as "4/4 workers busy, no progress".
const TURN_SLICE: Duration = Duration::from_millis(100);
/// Most extracted rows a root holds back between turns. Not `quantum`: a row
/// carries up to `maximum_text_size` of text, and 500 of those would be
/// 128 MiB per root. At 64 it is 16 MiB.
@ -485,7 +476,9 @@ pub(super) struct RunCx<'a> {
/// text files without handing them to the content pass.
pub(super) registry: Arc<Registry>,
pub(super) quantum: usize,
/// See [`TURN_SLICE`]; a field so tests can shrink it.
/// Writer time one root's turn may take before the round moves on; see
/// [`crate::config::ProcessingConfig::writer_turn_slice_ms`], which is
/// where the default and the reasoning live. Zero is one quantum a turn.
pub(super) slice: Duration,
/// 128-bit path digests, not paths: at millions of files, owning every
/// path string again was the single largest allocation in a run. See
@ -514,7 +507,7 @@ impl<'a> RunCx<'a> {
stop_flag,
registry: Arc::new(Registry::default_set()),
quantum: config.processing.batch_size.max(1),
slice: TURN_SLICE,
slice: Duration::from_millis(config.processing.writer_turn_slice_ms),
seen_paths: HashSet::new(),
stale_candidates: Vec::new(),
aliased_paths: HashSet::new(),
@ -817,6 +810,15 @@ impl IndexingService {
progressed |= p.service_walking(&mut cx)?;
}
}
// Between the stages, not only at the end of the round: a root
// enters `Extracting` in the walk stage above, and the stage
// below can finish its pass in the same round. Published once a
// round, the whole phase falls between two snapshots whenever a
// root's content pass is short — a small root reads as
// `Walking → Done`, having never reported the phase it spent its
// extraction in.
publish_status(status, run_start, &pipelines);
for k in 0..n {
let p = &mut pipelines[(rr + k) % n];
if p.phase == RootPhase::Extracting {

View file

@ -170,8 +170,6 @@ fn an_extracting_turn_lands_its_leftovers_one_slice_at_a_time() {
parent: &tree.to_string_lossy(),
size: 22,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -185,7 +183,6 @@ fn an_extracting_turn_lands_its_leftovers_one_slice_at_a_time() {
name: format!("f{}.txt", i),
outcome: ContentOutcome::Done {
text: format!("sphinx of black quartz {}", i),
properties: Vec::new(),
},
});
}

View file

@ -11,8 +11,8 @@
//! 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
//! filtering rules against each stored path.
//! * The content filter moved. The rows stay; only their extracted text,
//! properties and FTS entry are re-decided.
//! * The content filter moved. The rows stay; only their extracted text
//! and FTS entry are re-decided.
//!
//! Only settings that make stored data unreadable or incomparable — the FTS
//! tokenizer, the hash length, the encryption key — still force a wipe. See

View file

@ -54,8 +54,6 @@ fn seed(conn: &mut Connection, paths: &[PathBuf]) {
parent,
size: 1,
mtime: 1,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: crate::mime::FileType::TEXT,
hash: None,

View file

@ -100,8 +100,6 @@ mod tests {
parent: "/d",
size,
mtime: 1_700_000_000,
inode: None,
device_id: None,
mime: None,
ftype: FileType::TEXT,
hash,

View file

@ -16,9 +16,8 @@ fn db_with(tag: &str, rows: &[(String, u64)]) -> PathBuf {
for (path, mtime) in rows {
let as_path = Path::new(path);
conn.execute(
"INSERT INTO files (name, path, parent, size, mtime, type, \
basic_state, content_state)
VALUES (?1, ?2, ?3, 0, ?4, 0, 1, 3)",
"INSERT INTO files (name, path, parent, size, mtime, type, content_state)
VALUES (?1, ?2, ?3, 0, ?4, 0, 3)",
rusqlite::params![
as_path.file_name().unwrap().to_string_lossy(),
path,

View file

@ -42,8 +42,6 @@ impl Seeder {
parent: dir,
size: 42,
mtime,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -54,7 +52,7 @@ impl Seeder {
.expect("unique path");
if let Some(text) = text {
let zstd = self.store_text.then(|| zstd_of(text)).flatten();
set_content_done(&tx, id, name, text, &[], zstd.as_deref()).unwrap();
set_content_done(&tx, id, text, zstd.as_deref()).unwrap();
}
tx.commit().unwrap();
id

View file

@ -91,11 +91,11 @@ fn encrypted_index_lifecycle() {
{
let conn = db::open_existing(&db_path.to_string_lossy(), true).unwrap();
conn.execute_batch(
"INSERT INTO files (name, path, parent, size, mtime, type, basic_state, content_state)
"INSERT INTO files (name, path, parent, size, mtime, type, content_state)
WITH RECURSIVE n(i) AS (
SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000
)
SELECT 'p' || i, '/pad/' || i, '/pad', 0, 0, 0, 1, 3 FROM n;
SELECT 'p' || i, '/pad/' || i, '/pad', 0, 0, 0, 3 FROM n;
DELETE FROM files WHERE parent = '/pad';",
)
.unwrap();

View file

@ -749,26 +749,22 @@ fn a_directory_that_becomes_unreadable_deletes_nothing() {
// ---------------------------------------------------------------------------
/// Everything about a file's indexed content that a user can observe: its
/// state, the stored snippet body, and its property rows.
type ContentRow = (String, i64, Option<String>, Option<i64>, String);
/// state, its failure reason, and the compressed size of its stored body.
type ContentRow = (String, i64, Option<String>, Option<i64>);
fn content_rows(db: &Path) -> Vec<ContentRow> {
let conn = rusqlite::Connection::open(db).unwrap();
let mut stmt = conn
.prepare(
"SELECT f.path, f.content_state, f.failure_msg, d.text_len,
COALESCE(GROUP_CONCAT(p.key || '=' || p.value, ','), '')
"SELECT f.path, f.content_state, ff.reason, LENGTH(d.text_zstd)
FROM files f
LEFT JOIN documents_text d ON d.file_id = f.id
LEFT JOIN properties p ON p.file_id = f.id
GROUP BY f.id
LEFT JOIN failed_files ff ON ff.file_id = f.id
ORDER BY f.path",
)
.unwrap();
let out = stmt
.query_map([], |r| {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))
})
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))
.unwrap()
.map(|r| r.unwrap())
.collect();
@ -902,7 +898,9 @@ fn undecodable_small_files_are_reported_as_failures_not_silently_skipped() {
let conn = rusqlite::Connection::open(&db).unwrap();
let (state, msg): (i64, Option<String>) = conn
.query_row(
"SELECT content_state, failure_msg FROM files WHERE path LIKE '%bad.txt'",
"SELECT f.content_state, ff.reason FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.path LIKE '%bad.txt'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
@ -943,7 +941,9 @@ fn an_unreadable_legacy_office_file_fails_with_a_reason() {
let conn = rusqlite::Connection::open(&db).unwrap();
let (state, msg): (i64, Option<String>) = conn
.query_row(
"SELECT content_state, failure_msg FROM files WHERE path LIKE '%broken.doc'",
"SELECT f.content_state, ff.reason FROM files f \
LEFT JOIN failed_files ff ON ff.file_id = f.id \
WHERE f.path LIKE '%broken.doc'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
@ -1253,6 +1253,12 @@ struct Overlap {
/// The heavy root's `extract_total` and pool size, for the fixture guards.
heavy_pending: usize,
heavy_pool: usize,
/// Distinct published states seen inside the window, counted by the
/// counters changing rather than by polls — the watcher polls far faster
/// than the writer publishes, so poll count would say nothing. One is a
/// window that was raced past, not a window that was measured, and the
/// deltas either side of it are meaningless.
samples: usize,
}
/// Watch a two-root run until the heavy root has finished extracting and
@ -1262,11 +1268,25 @@ struct Overlap {
/// Deltas across the overlap, never durations. Sparse samples cost only the
/// window's edges, and they trim both counters together. Panics if the window
/// never opened — a fixture that does not exercise the case proves nothing.
///
/// What can only be seen here is what the writer *published*, once a round
/// (`publish_status` in `indexing/pipeline.rs`). A caller whose heavy root
/// finishes its content pass inside one round leaves no snapshot holding both
/// phases at once and lands on the panic below however healthy the run was —
/// which is why the callers set `writer_turn_slice_ms` small enough that a
/// round is far shorter than the pass, and why the panic prints what the
/// phases actually did.
fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str) -> Overlap {
let mut opened: Option<(usize, usize)> = None; // (light.walked, heavy.extracted)
let mut last = (0usize, 0usize);
let mut heavy_pending = 0usize;
let mut heavy_pool = 0usize;
let mut samples = 0usize;
let mut counted: Option<(usize, usize)> = None;
// Every (heavy, light) phase pair published, in order and without repeats.
// Only the diagnosis uses it: both phases are monotone, so this is at most
// a handful of entries and it says exactly which phase went missing.
let mut phases: Vec<(RootPhase, RootPhase)> = Vec::new();
let deadline = Instant::now() + Duration::from_secs(120);
while Instant::now() < deadline {
let mut in_window = false;
@ -1275,6 +1295,9 @@ fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str)
let heavy_p = roots.iter().find(|r| r.root.contains(heavy_tag));
let light_p = roots.iter().find(|r| r.root.contains(light_tag));
if let (Some(h), Some(l)) = (heavy_p, light_p) {
if phases.last() != Some(&(h.phase, l.phase)) {
phases.push((h.phase, l.phase));
}
// The light root's *walk* is what used to be starved, so the
// window closes with it — past that there is no drain left
// to observe, and `walked` is the only counter in play.
@ -1282,6 +1305,12 @@ fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str)
if in_window {
last = (l.walked, h.extracted);
opened.get_or_insert(last);
// A new publication, not a new poll: the counters only
// move when the writer has published a fresh round.
if counted != Some(last) {
counted = Some(last);
samples += 1;
}
if let Some(total) = h.extract_total {
heavy_pending = total;
}
@ -1306,8 +1335,13 @@ fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str)
let Some((light_open, heavy_open)) = opened else {
panic!(
"never observed the heavy root extracting while the light root walked; \
the fixture is not exercising the case"
"never observed the heavy root extracting while the light root walked. \
Published (heavy, light) phases: {:?}. An empty list means neither \
root matched the tags {:?}/{:?}; a list with no heavy Extracting in \
it means the heavy root's content pass began and ended between two \
status publications, so lower `writer_turn_slice_ms` until a writer \
round is shorter than that pass.",
phases, heavy_tag, light_tag
);
};
Overlap {
@ -1315,6 +1349,7 @@ fn observe_overlap(service: &IndexingService, heavy_tag: &str, light_tag: &str)
heavy_stored: last.1 - heavy_open,
heavy_pending,
heavy_pool,
samples,
}
}
@ -1365,12 +1400,15 @@ fn a_heavy_root_does_not_stall_a_light_one() {
const QUANTUM: usize = 16;
// HEAVY: few files, each big enough that reading one is real work, with a
// small `maximum_text_size` so the cost lands in extraction rather than in
// the writer's tokenising. Few and large rather than many and small: the
// margin is set by the cost of *one* heavy file against one light one,
// while the fixture's size on disk is their product — so for a given number
// of bytes, bigger files discriminate better. If a host ever comes in
// short, raise this size; raising the count only lengthens the window.
const HEAVY_FILES: usize = 32;
// the writer's tokenising. Few and large rather than many and small, and
// more so than it first looks. What the light root manages to drain
// depends only on the heavy root's *total* bytes — that is what sets how
// long the pass runs — while the bound it must beat, `3 × (rows +
// quantum)`, grows with the row count. So for a fixture of a given size on
// disk, every file it is split into costs margin. These are as large as
// `maximum_text_file_size` below allows and there are as few of them as
// the half-the-rows guard tolerates.
const HEAVY_FILES: usize = 12;
// LIGHT: a wide tree of tiny files, so its walk outlasts the heavy root's
// extraction and its counter moves finely. Each is inlined by its walk
// worker, so this root has no extraction phase of its own to confuse the
@ -1383,11 +1421,14 @@ fn a_heavy_root_does_not_stall_a_light_one() {
const MIN_INTERLEAVE: usize = 3;
let heavy = tmp_dir("stall-heavy");
// 36 bytes a repeat, so just under the 8 MiB that `maximum_text_file_size`
// has to stay above; twelve of them is the same ~96 MB of fixture this
// always built, redistributed into files that discriminate better.
let body: Vec<u8> = "sphinx of black quartz judge my vow "
.repeat(80_000)
.repeat(233_000)
.into_bytes();
for i in 0..HEAVY_FILES {
touch(&heavy.join(format!("d{}/big{:04}.txt", i % 8, i)), &body);
touch(&heavy.join(format!("d{}/big{:04}.txt", i % 4, i)), &body);
}
let light = tmp_dir("stall-light");
for i in 0..LIGHT_FILES {
@ -1403,8 +1444,19 @@ fn a_heavy_root_does_not_stall_a_light_one() {
let mut config = test_config();
config.processing.maximum_text_size = 1024;
config.processing.maximum_text_file_size = 8 * 1024 * 1024;
// Above the heavy files, or `mark_oversize_pending_na` writes them off as
// N/A before the pass starts and there is no extraction phase at all.
config.processing.maximum_text_file_size = 16 * 1024 * 1024;
config.processing.batch_size = QUANTUM;
// The window is only as long as one worker takes to read the heavy root —
// a tenth of a second or so, and on a host whose /tmp is a tmpfs rather
// less. Published once a round, the default 100 ms slice makes a round of
// the same order as the whole phase, and the watcher then sees the heavy
// root go Walking → Done having never once been published as Extracting.
// At 2 ms the round is bounded by the work in it, so the pass spans tens
// of publications on any host. This is the fix for a CI failure that was
// pure sampling: nothing about the run was unhealthy.
config.processing.writer_turn_slice_ms = 2;
// One extraction thread for the heavy root, so its pass costs about what
// the broken design's inline read would and the two differ only in *which*
// thread pays for it. `root_workers` is keyed by the `indexing_paths`
@ -1434,6 +1486,15 @@ fn a_heavy_root_does_not_stall_a_light_one() {
// The fixture is as configured. Each of these silently costs a factor of
// the margin below if it stops holding, so they are checked before the
// ratio is read as a verdict on the design.
assert!(
seen.samples >= 8,
"the overlap was published {} time(s); the deltas either side of a window \
that thin are noise, not a measurement. A writer round has to be much \
shorter than the heavy root's content pass lower writer_turn_slice_ms \
(currently {} ms) or give the heavy root more bytes to read",
seen.samples,
config.processing.writer_turn_slice_ms
);
assert_eq!(
seen.heavy_pool, 1,
"the heavy root must extract on the single worker root_workers asked for; \
@ -1454,11 +1515,12 @@ fn a_heavy_root_does_not_stall_a_light_one() {
eprintln!(
"light files drained while the heavy root extracted: {} against {} heavy \
rows (quantum {}) {}x the {}x required; the serialised design cannot \
exceed 1x",
rows (quantum {}, {} publications) {}x the {}x required; the \
serialised design cannot exceed 1x",
seen.light_drained,
seen.heavy_stored,
QUANTUM,
seen.samples,
seen.light_drained / (seen.heavy_stored + QUANTUM),
MIN_INTERLEAVE
);
@ -1487,7 +1549,8 @@ fn a_heavy_root_does_not_stall_a_light_one() {
/// Before turns had a slice, an extraction turn wrote everything it found —
/// half a second to two seconds of tokenising — and the light root's walk got
/// one quantum in between: the ratio below came in under one. With turns
/// bounded by `TURN_SLICE` and walks served first, the light root drains at
/// bounded by `writer_turn_slice_ms` and walks served first, the light root
/// drains at
/// its own rate while the heavy root lands a row or two per round.
#[test]
fn a_heavy_root_does_not_stall_a_light_one_at_the_writer() {
@ -1523,6 +1586,13 @@ fn a_heavy_root_does_not_stall_a_light_one_at_the_writer() {
let mut config = test_config();
config.processing.batch_size = QUANTUM;
// As in the sibling, and for the same reason: the status is published once
// a writer round, so a round has to be short against the phase being
// watched or the window is a race rather than a measurement. This one's
// pass is long — the writer tokenises 256 KiB a row — but nothing about
// the fixture guarantees that on a host whose FTS5 is faster than this
// one's, and it costs nothing to not depend on it.
config.processing.writer_turn_slice_ms = 2;
config.paths.indexing_paths = roots.clone();
// Four readers, so the heavy rows reach the writer faster than it can
// tokenise them and the ready channel is full when its turn comes.
@ -1539,6 +1609,13 @@ fn a_heavy_root_does_not_stall_a_light_one_at_the_writer() {
std::fs::remove_dir_all(&light).ok();
std::fs::remove_dir_all(&db_dir).ok();
assert!(
seen.samples >= 8,
"the overlap was published {} time(s); the deltas either side of a window \
that thin are noise, not a measurement (writer_turn_slice_ms {})",
seen.samples,
config.processing.writer_turn_slice_ms
);
assert_eq!(
seen.heavy_pool, 4,
"the heavy root must extract on four workers"
@ -1560,10 +1637,11 @@ fn a_heavy_root_does_not_stall_a_light_one_at_the_writer() {
eprintln!(
"light files drained while the heavy root tokenised: {} against {} heavy \
rows (quantum {}) {}x the {}x required",
rows (quantum {}, {} publications) {}x the {}x required",
seen.light_drained,
seen.heavy_stored,
QUANTUM,
seen.samples,
seen.light_drained / (seen.heavy_stored + QUANTUM),
MIN_INTERLEAVE
);

View file

@ -110,7 +110,6 @@ fn orphans(db: &Path) -> i64 {
[
("searchabletext", "rowid"),
("documents_text", "file_id"),
("properties", "file_id"),
("failed_files", "file_id"),
]
.iter()

View file

@ -124,8 +124,6 @@ fn seed(path: &std::path::Path) {
parent: &dir,
size: 4096,
mtime: 1_700_000_000 + i as u64,
inode: None,
device_id: None,
mime: Some("text/plain"),
ftype: FileType::TEXT,
hash: None,
@ -139,7 +137,7 @@ fn seed(path: &std::path::Path) {
.map(|_| WORDS[(rng.next() as usize) % WORDS.len()])
.collect();
let body = body.join(" ");
set_content_done(&tx, id, &name, &body, &[], zstd_of(&body).as_deref()).unwrap();
set_content_done(&tx, id, &body, zstd_of(&body).as_deref()).unwrap();
}
}
tx.commit().unwrap();

View file

@ -157,15 +157,14 @@ fn snippet_paths_perf_comparison() {
// measured against the same DB layout production runs against.
conn.execute_batch(
"CREATE VIRTUAL TABLE st_contentless USING fts5(
name, text,
text,
tokenize='trigram remove_diacritics 1',
content='',
contentless_delete=1
);
CREATE TABLE documents_text (
file_id INTEGER PRIMARY KEY,
text_zstd BLOB NOT NULL,
text_len INTEGER NOT NULL
text_zstd BLOB NOT NULL
);",
)
.unwrap();
@ -180,23 +179,19 @@ fn snippet_paths_perf_comparison() {
.prepare("INSERT INTO st_regular(rowid, name, text) VALUES (?1, ?2, ?3)")
.unwrap();
let mut ins_con = tx
.prepare("INSERT INTO st_contentless(rowid, name, text) VALUES (?1, ?2, ?3)")
.prepare("INSERT INTO st_contentless(rowid, text) VALUES (?1, ?2)")
.unwrap();
let mut ins_blob = tx
.prepare(
"INSERT INTO documents_text(file_id, text_zstd, text_len) VALUES (?1, ?2, ?3)",
)
.prepare("INSERT INTO documents_text(file_id, text_zstd) VALUES (?1, ?2)")
.unwrap();
for i in 1..=NUM_DOCS {
let target = 50 + ((rng as usize) % 400);
let text = seed_text(&mut rng, target);
let name = format!("doc_{:05}.txt", i);
ins_reg.execute(params![i as i64, &name, &text]).unwrap();
ins_con.execute(params![i as i64, &name, &text]).unwrap();
ins_con.execute(params![i as i64, &text]).unwrap();
let compressed = zstd::encode_all(text.as_bytes(), 3).unwrap();
ins_blob
.execute(params![i as i64, &compressed, text.len() as i64])
.unwrap();
ins_blob.execute(params![i as i64, &compressed]).unwrap();
}
}
tx.commit().expect("seed commit");

View file

@ -43,8 +43,19 @@ eframe = { version = "0.32", default-features = false, features = [
"glow",
"persistence",
] }
egui = "0.32"
egui_extras = "0.32"
# `default-features = false` for one feature, `default_fonts`, which embeds
# four TTFs totalling 1,407,752 bytes - 38% of `.rodata`. The
# `default-features = false` on eframe above does not cover it: feature
# resolution unions the two declarations, so naming egui plainly here switched
# it back on for the whole graph. `src/fonts.rs` installs the two faces the app
# actually paints with; the two emoji faces, 736,668 bytes of it, are gone.
# `persistence`/`serde` still reach egui through eframe, so nothing else moves.
egui = { version = "0.32", default-features = false }
# Only `Column`/`TableBuilder` are used. The lone default feature is
# `dep:mime_guess2`, for an image loader that is never registered - the linker
# already dropped every byte of it, so this compiles one crate less and ships
# the same bytes.
egui_extras = { version = "0.32", default-features = false }
rfd = "0.15"
open = "5"
chrono = { version = "0.4", default-features = false, features = ["clock"] }

Binary file not shown.

View file

@ -0,0 +1,31 @@
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.

View file

@ -0,0 +1,96 @@
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

View file

@ -378,7 +378,7 @@ fn reconcile_owed_banner(ctx: &egui::Context) -> Option<ReconcileOwedChoice> {
ui.horizontal(|ui| {
ui.colored_label(
ui.visuals().warn_fg_color,
"Your indexing settings have not been applied to the index yet.",
"Your indexing settings have not been applied to the index yet.",
);
if ui.small_button("Start indexing now").clicked() {
choice = Some(ReconcileOwedChoice::StartIndexing);
@ -505,7 +505,7 @@ mod tests {
KeySource::Prompt,
KeySource::Keychain,
] {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
assert!(
!frame(&ctx, source, Vec::new()),
"an untouched frame must not request a rebuild"
@ -546,7 +546,7 @@ mod tests {
#[test]
fn the_unsaved_modal_reports_each_choice() {
for source in [UnsavedSource::Manage, UnsavedSource::Settings] {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
assert_eq!(
modal_frame(&ctx, source, Vec::new()),
None,
@ -598,7 +598,7 @@ mod tests {
/// Both ways out of the quit warning work, and neither is the default.
#[test]
fn the_quit_warning_reports_both_answers() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
assert_eq!(
reconcile_modal_frame(&ctx, Vec::new()),
None,
@ -641,7 +641,7 @@ mod tests {
/// Both banner buttons report their clicks.
#[test]
fn the_reconcile_banner_reports_both_buttons() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
assert_eq!(banner_frame(&ctx, Vec::new()), None);
let mut seen = std::collections::HashSet::new();

View file

@ -48,7 +48,7 @@ fn confirm_frame(
/// dead button says so more clearly than a rejected attempt would.
#[test]
fn the_confirmation_will_not_submit_an_empty_password() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut pw = String::new();
let (out, buttons) = confirm_frame(&ctx, &mut pw, false, Vec::new());
assert_eq!(buttons, (false, false));
@ -60,7 +60,7 @@ fn the_confirmation_will_not_submit_an_empty_password() {
#[test]
fn the_confirmation_submits_a_typed_password_and_cancels_on_request() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut pw = "hunter2".to_string();
let (out, _) = confirm_frame(&ctx, &mut pw, false, Vec::new());
assert!(
@ -82,7 +82,7 @@ fn the_confirmation_submits_a_typed_password_and_cancels_on_request() {
/// having ignored the first attempt.
#[test]
fn a_retry_says_the_password_was_wrong() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut pw = String::new();
let (quiet, _) = confirm_frame(&ctx, &mut pw, false, Vec::new());
assert!(
@ -124,7 +124,7 @@ fn a_password_that_derives_another_key_reveals_nothing() {
#[test]
fn the_reveal_shows_the_key_and_what_holding_it_means() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let display = format!("0x{KEY}");
let painted = painted_text(&frame(&ctx, &display, Vec::new()).0);
@ -146,7 +146,7 @@ fn the_reveal_shows_the_key_and_what_holding_it_means() {
fn both_of_the_reveal_buttons_report_their_click() {
let display = format!("0x{KEY}");
for (label, expected) in [("Copy", (true, false)), ("Close", (false, true))] {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let (out, _) = frame(&ctx, &display, Vec::new());
let pos =
painted_text_center(&out, label).unwrap_or_else(|| panic!("no {label} button painted"));

View file

@ -44,7 +44,7 @@ fn frame(
#[test]
fn a_run_in_progress_says_what_it_is_doing_and_offers_a_way_out() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let m = modal(
VerifyState::Running {
bytes_read: 5 * 1024 * 1024,
@ -68,7 +68,7 @@ fn a_run_in_progress_says_what_it_is_doing_and_offers_a_way_out() {
/// reports what it has rather than dividing by zero.
#[test]
fn a_run_with_no_denominator_yet_still_reports() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let m = modal(
VerifyState::Running {
bytes_read: 0,
@ -85,7 +85,7 @@ fn a_run_with_no_denominator_yet_still_reports() {
#[test]
fn a_clean_result_says_so_and_lists_what_was_read() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let m = modal(report(vec![Identical, Identical, Identical], 300), 3);
let painted = painted_text(&frame(&ctx, &m, Vec::new()).0);
assert!(
@ -103,7 +103,7 @@ fn a_clean_result_says_so_and_lists_what_was_read() {
/// The case the feature exists for: same size, same head, different bytes.
#[test]
fn a_mismatch_names_the_file_and_the_offset() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let m = modal(report(vec![Identical, DiffersAt(1_234_567)], 2), 2);
let painted = painted_text(&frame(&ctx, &m, Vec::new()).0);
assert!(
@ -119,7 +119,7 @@ fn a_mismatch_names_the_file_and_the_offset() {
#[test]
fn a_cancelled_run_says_so_rather_than_showing_a_verdict() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let m = modal(VerifyState::Cancelled, 2);
let painted = painted_text(&frame(&ctx, &m, Vec::new()).0);
assert!(
@ -146,7 +146,7 @@ fn both_dismiss_buttons_report_the_dismissal() {
("Close", VerifyState::Cancelled),
("Close", report(vec![Identical, Identical], 8)),
] {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let m = modal(state, 2);
let (out, _) = frame(&ctx, &m, Vec::new());
let pos =

View file

@ -39,6 +39,7 @@ fn frame(
actions = tab.ui(ui, busy);
});
});
crate::test_ui::assert_no_tofu(ctx, &out);
(out, actions)
}
@ -92,7 +93,7 @@ const PATHS: [&str; 3] = ["/a/img.raw", "/b/img.raw", "/c/img.raw"];
#[test]
fn a_group_header_offers_the_verification() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
let (menu, _) = context_menu_on(&ctx, &mut tab, false, &header);
@ -105,7 +106,7 @@ fn a_group_header_offers_the_verification() {
/// Clicking it asks for the whole group, not the one row it was asked from.
#[test]
fn verifying_asks_for_every_member_of_the_group() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, false, &header);
@ -128,7 +129,7 @@ fn verifying_asks_for_every_member_of_the_group() {
/// asked for rather than replacing what someone is reading.
#[test]
fn a_second_verification_is_refused_while_the_window_is_open() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
context_menu_on(&ctx, &mut tab, true, &header);
@ -148,7 +149,7 @@ fn a_second_verification_is_refused_while_the_window_is_open() {
/// thing: the rows are what someone is looking at when the question occurs.
#[test]
fn a_member_row_offers_the_verification_too() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = loaded(&PATHS);
let header = header_of(&ctx, &mut tab);
@ -174,7 +175,7 @@ fn a_member_row_offers_the_verification_too() {
#[test]
fn an_empty_result_says_so_rather_than_showing_an_empty_list() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = DuplicatesTab {
state: DupState::Loaded(Vec::new()),
};

View file

@ -0,0 +1,119 @@
//! The app's own font set.
//!
//! `egui` is declared with `default-features = false`, so it embeds no fonts
//! at all and `FontDefinitions::default()` is `empty()`. Everything the app
//! paints comes from the two faces below.
//!
//! They are the same two files egui would have bundled, copied verbatim and
//! carrying no `FontTweak` — exactly as epaint registers them — so metrics and
//! rendering are unchanged. What is gone is egui's two *emoji* faces,
//! `NotoEmoji-Regular` (418,804) and `emoji-icon-font` (317,864): 736,668 bytes
//! of `.rodata`, a fifth of the section, for four glyphs the UI used and none
//! it needed. Those four are now `↻`, `×` and a colour (see `search_tab` and
//! `manage_tab`).
//!
//! The faces have no CJK, Hebrew, Arabic, Devanagari or Hangul and never did,
//! so filenames in those scripts render as `◻` here just as they always have.
//! Emoji in filenames now join them; that is the one real regression.
use std::sync::Arc;
/// Proportional body text. Ubuntu Font Licence 1.0, unmodified —
/// `assets/fonts/UFL.txt`.
const UBUNTU_LIGHT: &[u8] = include_bytes!("../assets/fonts/Ubuntu-Light.ttf");
/// Monospace: paths, snippets, keys. MIT over public-domain DejaVu over the
/// Bitstream Vera licence — `assets/fonts/Hack-Regular.txt`.
const HACK_REGULAR: &[u8] = include_bytes!("../assets/fonts/Hack-Regular.ttf");
/// Install the two faces on `ctx`.
///
/// `Context::set_fonts` only queues the definitions; they are applied in
/// `begin_pass`, before any user code of that frame runs. So this takes effect
/// on frame 1 wherever it is called, as long as it is called before the first
/// `Context::run`.
pub fn install(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::empty();
fonts.font_data.insert(
"Ubuntu-Light".to_owned(),
Arc::new(egui::FontData::from_static(UBUNTU_LIGHT)),
);
fonts.font_data.insert(
"Hack".to_owned(),
Arc::new(egui::FontData::from_static(HACK_REGULAR)),
);
// Hack trails Ubuntu-Light in *both* families, which egui's own defaults do
// not do — there the proportional fallbacks are the two emoji faces. The
// bytes are linked either way, and it buys two things. It is the only
// remaining source of `◻`, the replacement glyph epaint reaches for when
// nothing has the character; without it proportional text falls back to a
// bare `?`. And it covers Greek, Cyrillic, Armenian, Georgian, arrows and
// box drawing that Ubuntu-Light lacks, all of which turn up in a filename.
// Armenian and Georgian names in fact render here for the first time.
fonts.families.insert(
egui::FontFamily::Proportional,
vec!["Ubuntu-Light".to_owned(), "Hack".to_owned()],
);
// Ubuntu-Light second, as epaint has it: "fallback for √ etc".
fonts.families.insert(
egui::FontFamily::Monospace,
vec!["Hack".to_owned(), "Ubuntu-Light".to_owned()],
);
ctx.set_fonts(fonts);
}
#[cfg(test)]
mod tests {
/// A tripwire on the Cargo feature, not on this module.
///
/// `builtin_font_names` is `&[]` exactly when `epaint/default_fonts` is
/// off, so this fails the moment some dependency edge unions the feature
/// back on and relinks all 1,407,752 bytes of bundled TTFs. Feature
/// unification is silent and is precisely how they got in.
/// The coverage contract the UI now depends on, and the proof that
/// `assert_no_tofu` can fail: the glyphs the tabs paint resolve, and the
/// emoji that used to come from the two dropped faces do not.
///
/// `↻` and `×` are the two characters the Search tab was moved onto; the
/// rest are what the status lines and buttons paint. `↻` is the load-bearing
/// one for *Proportional*: U+21BB is in Hack and not in Ubuntu-Light, so it
/// passing here is the proof that Hack really is in the proportional
/// fallback chain — which is what keeps `◻` available and stops epaint
/// degrading to a bare `?`.
///
/// `◻` itself cannot be asserted: `has_glyph` is
/// `glyph_info(c) != replacement_glyph`, so the replacement glyph always
/// reports as missing. That is epaint's own documented quirk, not ours.
#[test]
fn the_installed_faces_cover_what_the_ui_paints_and_no_more() {
let ctx = crate::test_ui::ctx();
// `Context::fonts` panics until the first pass has run.
let _ = ctx.run(egui::RawInput::default(), |_| {});
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
let id = egui::FontId::new(14.0, family.clone());
ctx.fonts(|fonts| {
for c in ['↻', '×', '·', '…', '•', '', '°', 'é', 'Ω', 'д'] {
assert!(fonts.has_glyph(&id, c), "{family:?} lost {c:?}");
}
// Dropped with NotoEmoji and emoji-icon-font. If these start
// resolving, the 736,668 bytes are back.
for c in ['⟳', '🗙', '⚠', '🔥', '📋'] {
assert!(!fonts.has_glyph(&id, c), "{family:?} still has {c:?}");
}
});
}
}
#[test]
fn egui_bundles_no_fonts_of_its_own() {
assert!(
egui::FontDefinitions::builtin_font_names().is_empty(),
"egui's `default_fonts` feature is back on: {:?}",
egui::FontDefinitions::builtin_font_names()
);
}
}

View file

@ -164,3 +164,26 @@ fn readme_path() -> Option<std::path::PathBuf> {
.chain(dir.ancestors().take(4).map(|d| d.join("README.md")))
.find(|p| p.is_file())
}
#[cfg(test)]
mod tests {
/// The Help tab is the largest block of prose in the app and the only tab
/// with no other test, which makes it the widest net for the one thing
/// dropping egui's emoji faces could break: a character with no glyph,
/// painted as `◻`.
#[test]
fn the_help_tab_paints_no_missing_glyphs() {
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(1000.0, 900.0), vec![]);
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
super::ui(ui);
});
});
assert!(
!crate::test_ui::painted_text(&out).is_empty(),
"the tab painted nothing, so the glyph check proves nothing"
);
crate::test_ui::assert_no_tofu(&ctx, &out);
}
}

View file

@ -16,6 +16,7 @@ mod capture;
mod cli;
mod color;
mod duplicates_tab;
mod fonts;
mod format;
mod help_tab;
mod hotkey;
@ -106,6 +107,11 @@ fn main() {
"QuickSearch",
native_options,
Box::new(move |cc| {
// First: egui is built without its bundled fonts, so a context
// starts with no faces at all and lays every string out at zero
// height. `set_fonts` is applied in the next `begin_pass`, and
// this closure is the last place that is still ahead of frame 1.
fonts::install(&cc.egui_ctx);
// On Windows the registration owns a hidden window whose messages
// the event loop must dispatch, so it must be made on that loop's
// thread with the loop running — this closure is the first place

View file

@ -631,7 +631,7 @@ fn watch_contents(ui: &mut egui::Ui, state: &IndexerState, config: &Config) {
ui.colored_label(
ui.visuals().warn_fg_color,
format!(
"Live updates off; reindexing every {}",
"Live updates off; reindexing every {}",
fmt_interval(config.indexing.reindex_interval_minutes)
),
)

View file

@ -112,11 +112,12 @@ fn frame(
) -> ManageActions {
WIDGETS.with(|w| w.borrow_mut().clear());
let mut actions = ManageActions::default();
let _ = ctx.run(raw_input(events), |ctx| {
let out = ctx.run(raw_input(events), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
actions = tab.ui(ui, state, cfg);
});
});
crate::test_ui::assert_no_tofu(ctx, &out);
actions
}
@ -171,7 +172,7 @@ fn staged_workers(tab: &ManageTab) -> Option<usize> {
/// that id, so a field that is renamed mid-run silently drops the edit.
#[test]
fn the_worker_field_keeps_its_identity_as_the_status_changes() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let cfg = cfg_with_root();
@ -213,7 +214,7 @@ fn the_worker_field_keeps_its_identity_as_the_status_changes() {
/// reporting progress the whole time.
#[test]
fn a_typed_worker_count_reaches_the_applied_config() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let cfg = cfg_with_root();
@ -259,7 +260,7 @@ fn a_typed_worker_count_reaches_the_applied_config() {
/// The other way to set the field: drag it.
#[test]
fn a_dragged_worker_count_is_staged() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let cfg = cfg_with_root();
let busy = running_state(&["/data"], None);
@ -315,7 +316,7 @@ fn root_progress(phase: RootPhase, walked: usize, walk_total: Option<usize>) ->
/// the files a walk emits; a finished root must show the exact count.
#[test]
fn a_finished_root_reports_its_exact_count_not_the_estimate() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let mut done = root_progress(RootPhase::Done, 261_088, Some(6_677_062));
done.extracted = 238_929;
@ -339,7 +340,7 @@ fn a_finished_root_reports_its_exact_count_not_the_estimate() {
/// holds survives the run that counted it.
#[test]
fn a_configured_root_shows_what_the_last_run_counted() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let text = frame_text(&ctx, &mut tab, &counted_state(1_234_567, 456_789)).join(" | ");
@ -354,7 +355,7 @@ fn a_configured_root_shows_what_the_last_run_counted() {
/// folder is empty — where the truth is that nothing has counted it yet.
#[test]
fn a_root_the_index_has_never_counted_says_so() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let text = frame_text(&ctx, &mut tab, &idle_state()).join(" | ");
@ -371,7 +372,7 @@ fn a_root_the_index_has_never_counted_says_so() {
/// rather than borrowing another root's numbers.
#[test]
fn figures_belong_to_the_root_they_were_counted_for() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let state = IndexerState {
root_counts: Arc::new(vec![RootCount {
@ -394,7 +395,7 @@ fn figures_belong_to_the_root_they_were_counted_for() {
/// already been walked, or the row would read as a hang at 100%.
#[test]
fn a_walking_root_shows_the_estimate_raised_to_what_it_has_walked() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let honest = frame_text(
@ -440,7 +441,7 @@ fn frame_spans(
#[test]
fn every_phase_word_is_painted_in_its_hint_color() {
for theme in [egui::Theme::Dark, egui::Theme::Light] {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
ctx.set_theme(theme);
let mut tab = ManageTab::new();
let colors = crate::color::palette(theme == egui::Theme::Dark);
@ -461,7 +462,7 @@ fn every_phase_word_is_painted_in_its_hint_color() {
/// No count has landed yet: an indeterminate row, not a fabricated one.
#[test]
fn a_walking_root_without_a_count_shows_no_denominator() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let text = frame_text(
&ctx,
@ -476,7 +477,7 @@ fn a_walking_root_without_a_count_shows_no_denominator() {
/// Every step of the prologue names itself and carries a clock.
#[test]
fn each_prologue_step_says_what_it_is_waiting_on() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
for (step, expected) in [
@ -494,7 +495,7 @@ fn each_prologue_step_says_what_it_is_waiting_on() {
/// something to count. It reports its position in the scan.
#[test]
fn a_reconcile_reports_how_far_through_the_index_it_is() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let text = frame_text(
&ctx,
@ -521,7 +522,7 @@ fn a_reconcile_reports_how_far_through_the_index_it_is() {
/// with nothing to divide by; it must not invent a denominator.
#[test]
fn a_reconcile_without_a_row_count_shows_no_denominator() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let text = frame_text(
&ctx,
@ -537,7 +538,7 @@ fn a_reconcile_without_a_row_count_shows_no_denominator() {
/// `Idle` while the thread scans every row.
#[test]
fn a_prune_between_runs_is_reported_instead_of_idle() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let state = IndexerState {
reconcile: Some(ReconcileState::Running(ReconcileProgress {
@ -567,7 +568,7 @@ fn a_prune_between_runs_is_reported_instead_of_idle() {
/// is the only evidence it happened.
#[test]
fn a_finished_prune_reports_what_it_did() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let state = IndexerState {
reconcile: Some(ReconcileState::Finished(ReconcileProgress {
@ -597,7 +598,7 @@ fn a_finished_prune_reports_what_it_did() {
/// The static "Starting…" placeholder must not reappear.
#[test]
fn the_starting_placeholder_is_gone() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
for state in [
preparing_state(PrepStep::PreviousRun),
@ -714,7 +715,7 @@ fn the_status_row_shows_the_total_index_size() {
write_bytes(&dir.join("index.sqlite-wal"), 200_000);
write_bytes(&dir.join("index.sqlite-shm"), 32_768);
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let text = frame_text_with(&ctx, &mut tab, &cfg_with_db(&db), &idle_state()).join(" | ");
assert!(
@ -733,7 +734,7 @@ fn hovering_the_size_explains_how_to_shrink_the_index() {
let db = dir.join("index.sqlite");
write_bytes(&db, 2048);
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
// egui holds tooltips back for a third of a second, and frames here
// are only 1/60 s of simulated time apart.
ctx.style_mut(|s| s.interaction.tooltip_delay = 0.0);
@ -993,7 +994,7 @@ fn a_rejected_apply_keeps_the_draft() {
/// which egui hangs interaction state off.
#[test]
fn the_unsaved_label_appears_without_renaming_the_apply_button() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let cfg = cfg_with_root();
@ -1024,7 +1025,7 @@ fn the_unsaved_label_appears_without_renaming_the_apply_button() {
/// changes mid-edit loses its buffer.
#[test]
fn the_prune_rows_come_and_go_without_renaming_anything_below() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = ManageTab::new();
let cfg = cfg_with_root();
let progress = ReconcileProgress {

View file

@ -1007,7 +1007,7 @@ impl SearchTab {
// loses focus and its in-progress edit. Nothing follows the
// button here, so the ordering alone is the fix.
if ui
.place(slot, egui::Button::new("").frame_when_inactive(false))
.place(slot, egui::Button::new("").frame_when_inactive(false))
.on_hover_text("Run this search again")
.clicked()
{
@ -1025,7 +1025,7 @@ impl SearchTab {
let mut remove: Option<usize> = None;
for (i, pattern) in self.session_ignores.iter().enumerate() {
if ui
.small_button(format!("{} 🗙", pattern))
.small_button(format!("{} ×", pattern))
.on_hover_text("Remove this session filter")
.clicked()
{

View file

@ -31,11 +31,15 @@ fn run_frame(
events: Vec<egui::Event>,
) -> egui::FullOutput {
let input = crate::test_ui::raw_input(egui::vec2(1000.0, 700.0), events);
ctx.run(input, |ctx| {
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
tab.ui(ui);
});
})
});
// Every test in this file paints through here, so the glyph check rides
// along on all of them rather than being a test of its own.
crate::test_ui::assert_no_tofu(ctx, &out);
out
}
/// Walk the pointer down the name column until it sits on `row`'s label
@ -71,7 +75,7 @@ fn deep_path() -> String {
/// sits on and the directories right above it both stay on screen.
#[test]
fn long_paths_elide_from_the_middle_of_the_path_column() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let path = deep_path();
tab.results[0].path = path.clone();
@ -97,7 +101,7 @@ fn long_paths_elide_from_the_middle_of_the_path_column() {
/// appears while *it* did the eliding, so pre-shortened text brings its own.
#[test]
fn an_elided_path_still_shows_the_whole_thing_on_hover() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
// Testing that the tooltip is wired up, not egui's hover timing.
ctx.style_mut(|s| {
s.interaction.tooltip_delay = 0.0;
@ -534,7 +538,7 @@ fn solid_from(ramp: &[(f32, u8)]) -> f32 {
/// Clearing the old results paints no scrim: it is a plain dip to nothing.
#[test]
fn clearing_results_paints_no_scrim() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(20);
let settled = run_frame(&ctx, &mut tab, vec![]);
@ -572,7 +576,7 @@ fn clearing_results_paints_no_scrim() {
/// section opacity, which is exactly zero on this frame.
#[test]
fn new_results_start_completely_covered() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(20);
run_frame(&ctx, &mut tab, vec![]);
@ -617,7 +621,7 @@ fn new_results_start_completely_covered() {
/// The reveal uncovers the table from the top down, first rows early.
#[test]
fn new_results_are_uncovered_from_the_top_down() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(20);
run_frame(&ctx, &mut tab, vec![]);
@ -680,7 +684,7 @@ fn the_selection_follows_its_file_across_batches() {
#[test]
fn rows_respond_over_selectable_label_text() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(3);
run_frame(&ctx, &mut tab, Vec::new());
@ -849,7 +853,7 @@ fn the_content_match_cell_stays_inside_its_column() {
/// the match once, so the tooltip is the *second* appearance.
#[test]
fn hovering_the_content_match_cell_shows_the_match_in_the_tooltip() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
// Testing that the tooltip carries the match, not egui's hover timing.
ctx.style_mut(|s| {
s.interaction.tooltip_delay = 0.0;
@ -954,7 +958,7 @@ fn highlight_runs(out: &egui::FullOutput, ctx: &egui::Context) -> Vec<String> {
/// table ships without them; both are one click away in the header menu.
#[test]
fn size_and_modified_are_off_by_default_and_can_be_switched_on() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let painted = painted_text(&run_frame(&ctx, &mut tab, vec![]));
@ -1190,7 +1194,7 @@ fn refitting_without_a_measurement_splits_the_budget_evenly() {
/// past the edge, unreachable and looking switched off.
#[test]
fn the_columns_follow_the_window_when_it_is_resized() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(3);
tab.columns = ColumnsConfig {
name: true,
@ -1280,7 +1284,7 @@ fn a_column_cannot_be_dragged_wider_than_the_slack_that_is_left() {
fn dragging_a_column_cannot_push_the_table_off_the_edge() {
const W: f32 = 900.0;
const STEP: f32 = 400.0;
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(3);
tab.columns = ColumnsConfig {
name: true,
@ -1483,7 +1487,7 @@ fn the_ceiling_is_what_the_columns_to_the_right_can_give() {
#[test]
fn a_dragged_divider_widens_its_column_and_stays_under_the_cursor() {
const W: f32 = 1000.0;
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(3);
tab.columns = ColumnsConfig {
name: true,
@ -1571,7 +1575,7 @@ fn a_dragged_divider_widens_its_column_and_stays_under_the_cursor() {
/// where everything else is switched off.
#[test]
fn the_path_column_survives_every_column_combination() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
for bits in 0..32u8 {
let mut tab = tab_with_results(1);
tab.columns = ColumnsConfig {
@ -1601,7 +1605,7 @@ fn the_path_column_survives_every_column_combination() {
/// was taken for.
#[test]
fn the_content_match_column_follows_only_its_checkbox() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(2);
assert!(
tab.results
@ -1639,7 +1643,7 @@ fn the_content_match_column_follows_only_its_checkbox() {
#[test]
fn right_clicking_any_header_opens_the_column_picker() {
for header in ["Name", "Path", "Rank"] {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let out = run_frame(&ctx, &mut tab, vec![]);
let pos = painted_text_center(&out, header)
@ -1656,7 +1660,7 @@ fn right_clicking_any_header_opens_the_column_picker() {
/// says — because there is no content match to show.
#[test]
fn a_filename_match_is_highlighted_in_the_name_column() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.results[0] = name_hit("quarterly_budget.txt", (10, 16));
@ -1676,7 +1680,7 @@ fn a_filename_match_is_highlighted_in_the_name_column() {
/// the truth.
#[test]
fn hiding_the_name_column_skips_its_highlight() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.results[0] = name_hit("quarterly_budget.txt", (10, 16));
tab.columns.name = false;
@ -1694,7 +1698,7 @@ fn hiding_the_name_column_skips_its_highlight() {
/// answer, and this holds even if core regresses to windowing name snippets.
#[test]
fn a_name_snippet_that_is_not_the_name_paints_no_highlight() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let mut hit = name_hit("a_very_long_quarterly_budget_report.txt", (0, 6));
// What `window_around` used to hand back: a suffix, with rebased ranges.
@ -1720,7 +1724,7 @@ fn a_name_snippet_that_is_not_the_name_paints_no_highlight() {
/// switch off — so this highlight is always available.
#[test]
fn a_path_match_is_highlighted_in_the_path_column() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let path = "/qs-test/reports/alpha_widget_0.txt";
tab.results[0].path = path.to_string();
@ -1741,7 +1745,7 @@ fn a_path_match_is_highlighted_in_the_path_column() {
/// whatever glyphs happen to sit at those offsets in the shortened string.
#[test]
fn a_path_match_lost_to_elision_paints_no_stray_highlight() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let path = deep_path();
tab.results[0].path = path.clone();
@ -1767,7 +1771,7 @@ fn a_path_match_lost_to_elision_paints_no_stray_highlight() {
/// weak part: it is punctuation the renderer added, not part of the path.
#[test]
fn the_path_column_paints_at_full_strength_but_marks_its_elision_weak() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let path = deep_path();
tab.results[0].path = path.clone();
@ -1799,7 +1803,7 @@ fn the_path_column_paints_at_full_strength_but_marks_its_elision_weak() {
/// mark to place and paints the cell in one run.
#[test]
fn a_path_that_fits_is_painted_whole_at_full_strength() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let path = tab.results[0].path.clone();
@ -1817,9 +1821,9 @@ fn a_path_that_fits_is_painted_whole_at_full_strength() {
/// what is on screen.
#[test]
fn the_repeat_button_tracks_the_search_it_would_repeat() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let has_button = |out: &egui::FullOutput| painted_text(out).contains(&"".to_string());
let has_button = |out: &egui::FullOutput| painted_text(out).contains(&"".to_string());
assert!(
!has_button(&run_frame(&ctx, &mut tab, vec![])),
@ -1869,10 +1873,10 @@ fn completed_tab(ctx: &egui::Context) -> SearchTab {
#[test]
fn clicking_the_repeat_button_asks_for_a_rerun() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = completed_tab(&ctx);
let out = run_frame(&ctx, &mut tab, vec![]);
let pos = painted_text_center(&out, "").expect("no repeat button painted");
let pos = painted_text_center(&out, "").expect("no repeat button painted");
let (_, actions) = run_frame_actions(&ctx, &mut tab, click_at(pos));
assert!(actions.rerun, "the repeat button reported nothing");
}
@ -1884,7 +1888,7 @@ fn clicking_the_repeat_button_asks_for_a_rerun() {
/// even if focus had been dropped and handed back.
#[test]
fn the_repeat_button_does_not_steal_the_query_box() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.query = "alpha".into();
tab.focus_query = true;
@ -1901,7 +1905,7 @@ fn the_repeat_button_does_not_steal_the_query_box() {
1000,
);
let out = run_frame(&ctx, &mut tab, vec![]);
assert!(painted_text(&out).contains(&"".to_string()));
assert!(painted_text(&out).contains(&"".to_string()));
run_frame(&ctx, &mut tab, vec![egui::Event::Text("x".into())]);
assert!(
@ -1915,7 +1919,7 @@ fn the_repeat_button_does_not_steal_the_query_box() {
/// from jumping sideways every time a search finishes.
#[test]
fn the_query_text_does_not_shift_when_the_repeat_button_appears() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.query = "alpha".into();
@ -1938,14 +1942,14 @@ fn the_query_text_does_not_shift_when_the_repeat_button_appears() {
1000,
);
let out = run_frame(&ctx, &mut tab, vec![]);
assert!(painted_text(&out).contains(&"".to_string()));
assert!(painted_text(&out).contains(&"".to_string()));
assert_eq!(without, rect_of(&out), "the query text moved");
}
/// Left to right: syntax help, the box, how long the search took, then Fuzzy.
#[test]
fn the_query_strip_reads_help_box_duration_fuzzy() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = completed_tab(&ctx);
let out = run_frame(&ctx, &mut tab, vec![]);
@ -1971,7 +1975,7 @@ fn the_query_strip_reads_help_box_duration_fuzzy() {
/// `ui.checkbox` had.
#[test]
fn the_fuzzy_label_is_left_of_its_box_and_still_toggles() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = completed_tab(&ctx);
let out = run_frame(&ctx, &mut tab, vec![]);
let label = painted(&out)
@ -2026,7 +2030,7 @@ fn a_capped_count_says_so_without_the_word_truncated() {
/// removing the status bar's wording must not take it with it.
#[test]
fn the_in_tab_notice_still_explains_the_cap() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(3);
tab.limited = true;
let painted = painted_text(&run_frame(&ctx, &mut tab, vec![]));
@ -2042,7 +2046,7 @@ fn the_in_tab_notice_still_explains_the_cap() {
/// it is the visible set, not everything the search returned.
#[test]
fn only_the_rendered_rows_are_offered_for_watching() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
// A settled tab: no search running, nothing pending, no reveal underway.
let mut tab = tab_with_results(500);
run_frame(&ctx, &mut tab, vec![]);
@ -2064,7 +2068,7 @@ fn only_the_rendered_rows_are_offered_for_watching() {
/// debounce to fire the next search.
#[test]
fn editing_the_query_drops_the_watches() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.live_armed = vec![live_target("/qs-test/alpha_widget_0.txt")];
tab.focus_query = true;
@ -2152,7 +2156,7 @@ fn the_watch_set_is_the_paths_and_the_tier_and_not_the_baseline() {
/// leave the watcher pointed at a file that is no longer there.
#[test]
fn a_renamed_row_is_re_armed_at_its_new_path() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(2);
run_frame(&ctx, &mut tab, vec![]);
std::thread::sleep(LIVE_ARM_DELAY);
@ -2181,7 +2185,7 @@ fn a_renamed_row_is_re_armed_at_its_new_path() {
/// into a check of the index against the disk.
#[test]
fn a_target_carries_what_the_row_is_displaying() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(2);
tab.results[0].size = 4242;
tab.results[0].mtime = 1_710_000_000;
@ -2231,7 +2235,7 @@ fn a_rename_updates_the_row_in_place() {
/// it would shift everything below while someone is reading.
#[test]
fn a_vanished_file_is_struck_through_and_comes_back() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
let path = tab.results[0].path.clone();
@ -2267,7 +2271,7 @@ fn a_vanished_file_is_struck_through_and_comes_back() {
/// Content Match column on if it was not already.
#[test]
fn a_content_change_repaints_the_highlight() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.results[0].stage = 6;
let path = tab.results[0].path.clone();
@ -2297,7 +2301,7 @@ fn a_content_change_repaints_the_highlight() {
/// fall back to its dash rather than keep showing text that is no longer a hit.
#[test]
fn an_edit_that_removes_the_match_clears_the_content_cell() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.results[0].stage = 6;
tab.results[0].snippet = Some(Snippet {
@ -2347,7 +2351,7 @@ fn a_content_change_leaves_a_name_hit_s_snippet_alone() {
/// says so. With it hidden, the path — which is always shown — carries it.
#[test]
fn a_vanished_file_is_legible_with_the_name_column_hidden() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tab = tab_with_results(1);
tab.columns.name = false;
let path = tab.results[0].path.clone();

View file

@ -81,11 +81,13 @@ fn run_hotkey_edit(
events: Vec<egui::Event>,
) -> egui::FullOutput {
let input = crate::test_ui::raw_input(egui::vec2(600.0, 200.0), events);
ctx.run(input, |ctx| {
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
hotkey_edit(ui, setting, capturing);
});
})
});
crate::test_ui::assert_no_tofu(ctx, &out);
out
}
/// One frame of the color scheme control on its own, for the same reason
@ -96,18 +98,20 @@ fn run_color_scheme_edit(
events: Vec<egui::Event>,
) -> egui::FullOutput {
let input = crate::test_ui::raw_input(egui::vec2(600.0, 200.0), events);
ctx.run(input, |ctx| {
let out = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
color_scheme_edit(ui, setting);
});
})
});
crate::test_ui::assert_no_tofu(ctx, &out);
out
}
/// The dropdown says which scheme is in force and writes the one that is
/// picked; what it shows and what it stores are not the same string.
#[test]
fn the_color_scheme_box_shows_and_sets_the_scheme() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut setting = "dark".to_string();
let closed = run_color_scheme_edit(&ctx, &mut setting, vec![]);
@ -163,7 +167,7 @@ const CTRL_ALT: egui::Modifiers = egui::Modifiers {
/// pressed.
#[test]
fn the_shortcut_button_binds_what_was_pressed() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut setting = "Ctrl+Shift+F".to_string();
let mut capturing = false;
@ -195,7 +199,7 @@ fn the_shortcut_button_binds_what_was_pressed() {
/// through rather than treated as one.
#[test]
fn capture_ignores_what_it_cannot_bind_and_escape_cancels() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut setting = "Ctrl+Shift+F".to_string();
let mut capturing = true;
@ -221,7 +225,7 @@ fn capture_ignores_what_it_cannot_bind_and_escape_cancels() {
#[test]
fn clear_switches_the_shortcut_off() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut setting = "Ctrl+Shift+F".to_string();
let mut capturing = false;
@ -240,7 +244,7 @@ fn clear_switches_the_shortcut_off() {
/// app is actually holding, and until Apply they can disagree.
#[test]
fn an_unapplied_shortcut_says_it_is_not_in_force_yet() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let run = |draft: &str, live: &str| {
let input = crate::test_ui::raw_input(egui::vec2(600.0, 200.0), vec![]);
let out = ctx.run(input, |ctx| {
@ -315,7 +319,7 @@ const ROWS: &[(Section, &str, &tips::Tip)] = &[
#[test]
fn every_row_shows_its_own_tip() {
for (section, label, tip) in ROWS {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
ctx.style_mut(|s| {
s.interaction.tooltip_delay = 0.0;
s.interaction.show_tooltips_only_when_still = false;
@ -355,7 +359,7 @@ fn every_row_shows_its_own_tip() {
/// wiring is under test, so tooltip timing is turned off.
#[test]
fn hovering_a_setting_label_explains_it() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
ctx.style_mut(|s| {
s.interaction.tooltip_delay = 0.0;
s.interaction.show_tooltips_only_when_still = false;
@ -394,7 +398,7 @@ fn hovering_a_setting_label_explains_it() {
/// Apply & Save click comes back out as `applied`.
#[test]
fn the_tab_renders_and_apply_reports_the_draft() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let cfg = Config::default();
let mut w = SettingsTab::new();
w.stage(&cfg);
@ -406,6 +410,7 @@ fn the_tab_renders_and_apply_reports_the_draft() {
let full = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| out = w.ui(ui, &cfg));
});
crate::test_ui::assert_no_tofu(&ctx, &full);
(out, full)
};
@ -445,6 +450,7 @@ fn run_columns(
picked = columns_ui(ui, current);
});
});
crate::test_ui::assert_no_tofu(ctx, &full);
(picked, full)
}
@ -454,7 +460,7 @@ fn run_columns(
/// the moment a box moves, with no Apply.
#[test]
fn the_columns_block_reports_a_change_immediately() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let current = ColumnsConfig::default();
assert!(!current.size, "the fixture assumes Size ships off");
@ -480,7 +486,7 @@ fn the_columns_block_reports_a_change_immediately() {
/// question "why can I not remove it?" has an answer on screen.
#[test]
fn the_columns_block_offers_every_column_but_the_path() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let (_, full) = run_columns(&ctx, &ColumnsConfig::default(), vec![]);
let painted = painted_text(&full);
for label in ["Name", "Path", "Content Match", "Size", "Modified", "Rank"] {
@ -511,6 +517,7 @@ fn run_security(
action = security_ui(ui, current, false);
});
});
crate::test_ui::assert_no_tofu(ctx, &full);
(action, full)
}
@ -518,7 +525,7 @@ fn run_security(
/// could show and it is left out rather than shown dead.
#[test]
fn the_key_button_appears_only_while_the_index_is_encrypted() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut cfg = Config::default();
assert!(
!cfg.security.password_protected,
@ -545,7 +552,7 @@ fn the_key_button_appears_only_while_the_index_is_encrypted() {
/// both live in the app, so nothing about the key is decided here.
#[test]
fn clicking_the_key_button_reports_show_key() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let cfg = Config {
security: quicksearch_core::config::SecurityConfig {
password_protected: true,

View file

@ -27,10 +27,57 @@ pub fn click_at(pos: egui::Pos2) -> Vec<egui::Event> {
vec![egui::Event::PointerMoved(pos), button(true), button(false)]
}
/// A context carrying the fonts the app installs.
///
/// Not `egui::Context::default()`, which every test here used to call: egui is
/// built without `default_fonts`, so a default context has *no* faces. That
/// does not fail loudly — epaint hands back a font of `row_height` 0.0 and
/// zero-advance glyphs — so every measurement, every wrap and every click
/// target below would quietly stop meaning anything.
pub fn ctx() -> egui::Context {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
ctx
}
/// Assert that every character painted this frame has a real glyph in the
/// installed fonts — that nothing on screen is a `◻`.
///
/// The `FontId` comes from each layout section, so a monospace run is checked
/// against the monospace family and a proportional run against the
/// proportional one, exactly as epaint resolved them. Whitespace and controls
/// are skipped: epaint maps those to space-derived or invisible glyphs on
/// purpose, and `\n` is documented to report as the replacement.
///
/// Only sees what this frame actually painted, so its reach is the reach of
/// the test that calls it.
pub fn assert_no_tofu(ctx: &egui::Context, out: &egui::FullOutput) {
let mut missing: Vec<(char, String, egui::FontId)> = Vec::new();
ctx.fonts(|fonts| {
for (galley, _) in painted_galleys(out) {
for section in &galley.job.sections {
for c in galley.job.text[section.byte_range.clone()].chars() {
if c.is_whitespace() || c.is_control() {
continue;
}
if !fonts.has_glyph(&section.format.font_id, c) {
missing.push((
c,
galley.text().to_string(),
section.format.font_id.clone(),
));
}
}
}
}
});
assert!(missing.is_empty(), "no glyph for: {missing:#?}");
}
/// A `Ui` from a real (headless) egui pass, so measuring helpers see the
/// same fonts the app paints with.
pub fn with_ui<R>(f: impl FnOnce(&mut egui::Ui) -> R) -> R {
let ctx = egui::Context::default();
let ctx = ctx();
let mut f = Some(f);
let mut out = None;
let _ = ctx.run(egui::RawInput::default(), |ctx| {

View file

@ -777,7 +777,7 @@ mod tests {
/// caution. Written against the tip with all four.
#[test]
fn show_paints_every_part() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(800.0, 600.0), vec![]);
let full = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| TOKENIZER.show(ui));
@ -795,7 +795,7 @@ mod tests {
/// A lone example reads as a sentence rather than a one-item list.
#[test]
fn a_single_example_is_prefixed_with_example() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let input = crate::test_ui::raw_input(egui::vec2(800.0, 600.0), vec![]);
let full = ctx.run(input, |ctx| {
egui::CentralPanel::default().show(ctx, |ui| INCLUDE_HIDDEN.show(ui));
@ -811,7 +811,7 @@ mod tests {
/// disabled widget unless the *disabled* tooltip is set too.
#[test]
fn a_disabled_control_still_explains_itself() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
ctx.style_mut(|s| {
s.interaction.tooltip_delay = 0.0;
s.interaction.show_tooltips_only_when_still = false;

View file

@ -240,7 +240,7 @@ mod tests {
#[test]
fn every_page_paints_its_own_title_and_body() {
for (page, spec) in PAGES.iter().enumerate() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tour = Tutorial { page };
let (out, _) = frame(&ctx, &mut tour, Vec::new());
let painted = painted_text(&out);
@ -262,7 +262,7 @@ mod tests {
/// generic example.
#[test]
fn the_indexing_page_lists_the_configured_folders() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tour = Tutorial { page: 1 };
let roots = ["/srv/projects".to_string()];
let _ = ctx.run(raw_input(SCREEN, Vec::new()), |ctx| {
@ -344,7 +344,7 @@ mod tests {
/// have to come out the same size.
#[test]
fn the_footer_runs_back_then_skip_then_next_at_the_same_size() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let [back, skip, next] = footer_spans(&ctx, 1);
let back = back.expect("no Back button in the footer");
let skip = skip.expect("no Skip button in the footer");
@ -377,7 +377,7 @@ mod tests {
/// the tour off the front.
#[test]
fn the_first_page_cannot_go_back() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let y = footer_y(&ctx, 0);
for x in (150..850).step_by(4) {
let mut t = Tutorial { page: 0 };
@ -411,7 +411,7 @@ mod tests {
/// confirmation modals' tests take.
#[test]
fn skip_and_finish_both_dismiss() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
// Skip is on every page.
let mut tour = Tutorial { page: 0 };
@ -428,7 +428,7 @@ mod tests {
!sweep(&ctx, &mut tour).is_empty(),
"Finish never fired on the last page"
);
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut tour = Tutorial {
page: PAGES.len() - 1,
};

View file

@ -534,7 +534,7 @@ mod tests {
/// leave it.
#[test]
fn the_password_field_takes_focus_once_and_then_releases_it() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut screen = UnlockScreen::new(locked_config(), None, None);
frame(&ctx, &mut screen);
@ -560,7 +560,7 @@ mod tests {
/// was cleared on submit.
#[test]
fn a_failed_attempt_puts_the_caret_back() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut screen = UnlockScreen::new(locked_config(), None, None);
frame(&ctx, &mut screen);
ctx.memory_mut(|m| m.surrender_focus(pw_field_id()));
@ -576,7 +576,7 @@ mod tests {
/// the central one would compile and show nothing.
#[test]
fn the_lock_screen_shows_the_build_id() {
let ctx = egui::Context::default();
let ctx = crate::test_ui::ctx();
let mut screen = UnlockScreen::new(locked_config(), None, None);
let input = crate::test_ui::raw_input(SCREEN, Vec::new());

View file

@ -11,6 +11,25 @@ Files: crates/quicksearch-gui/assets/icons/*
Copyright: 2026 Jeremy <jeremy@karsttech.com>
License: GPL-3.0-or-later
Files: crates/quicksearch-gui/assets/fonts/Ubuntu-Light.ttf
Copyright: 2010-2011 Canonical Ltd.
License: Ubuntu-font-1.0
The full text is shipped beside the font as
crates/quicksearch-gui/assets/fonts/UFL.txt. The file is redistributed
unmodified, so the licence's naming conditions on Modified Versions do not
arise.
Files: crates/quicksearch-gui/assets/fonts/Hack-Regular.ttf
Copyright: 2018 Source Foundry Authors
2003 Bitstream, Inc.
License: MIT and Bitstream-Vera
Hack is MIT-licensed work over the public-domain DejaVu project, which is in
turn derived from Bitstream Vera Sans Mono under the Bitstream Vera License
with Reserved Font Names "Bitstream" and "Vera". The full text of both is
shipped beside the font as
crates/quicksearch-gui/assets/fonts/Hack-Regular.txt. The file is
redistributed unmodified, so the Bitstream renaming condition does not arise.
License: GPL-3.0-or-later
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@ -32,7 +51,7 @@ Comment:
/usr/bin/quicksearch and /usr/bin/quicksearch-cli are statically linked Rust
binaries built from the same crates. Their third-party components are not
shipped as separate files, so they are summarised here
rather than given individual stanzas. Every one of the 512 crates in
rather than given individual stanzas. Every one of the 525 crates in
Cargo.lock is under a permissive licence, all of which are compatible with
GPL-3.0-or-later:
.
@ -50,12 +69,14 @@ Comment:
rusqlite), Zstandard (BSD-3-Clause arm of its BSD/GPL-2.0 dual licence,
via zstd-sys) and bzip2 (BSD-style, via bzip2-sys).
.
The binary also embeds egui's default fonts (crate epaint_default_fonts,
"(MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0"). The FSF regards
OFL-1.1 and the Ubuntu Font Licence as free but GPL-incompatible; they are
included here as font data rather than as linked program code, which is
the same basis on which Debian ships these fonts and other egui-based
applications.
The binary also embeds the two font files under
crates/quicksearch-gui/assets/fonts/, which have their own stanzas above.
egui is built with default-features = false so that it bundles no fonts of
its own; the OFL-1.1 emoji faces it would otherwise embed are not shipped,
and OFL-1.1 accordingly no longer applies to this package. The FSF regards
the Ubuntu Font Licence as free but GPL-incompatible; it is included here as
font data rather than as linked program code, which is the same basis on
which Debian ships these fonts and other egui-based applications.
.
Run `cargo metadata --all-features` against the source tree to reproduce
the per-crate licence list.