diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..7d091c2 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,293 @@ +name: CI + +# Forgejo reads .forgejo/workflows before .github/workflows. Actions referenced +# bare (actions/checkout, actions/cache, ...) resolve through the instance's +# DEFAULT_ACTIONS_URL, which points at data.forgejo.org, so nothing here reaches +# out to github.com. + +on: + push: + branches: [master] + tags: ['v*'] + pull_request: + branches: [master] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + # Incremental artifacts are never reused between CI runs and would bloat the + # cached target/ tree for nothing. + CARGO_INCREMENTAL: '0' + RUST_BACKTRACE: '1' + +jobs: + # ---------------------------------------------------------------- linux ---- + # + # The container base is load-bearing: packaging/build-deb.sh derives the + # package's libc6 floor with objdump from the binary it just built, so the + # .deb inherits the *builder's* glibc. Ubuntu 22.04 fixes that floor at 2.35, + # which covers 22.04 LTS and newer plus Debian 12 and newer. Packages built by + # hand on a dev machine declared libc6 (>= 2.43) and installed on almost + # nothing. + # + # catthehacker/ubuntu is the act-compatible image family. A bare ubuntu:22.04 + # will not work: JS actions need Node already present in the image, and no + # step can install it before actions/checkout runs. + linux: + runs-on: forgejo-runner + container: + image: catthehacker/ubuntu:act-22.04 + env: + # crates/quicksearch-core/src/config.rs has a test that expects a home + # directory and panics without one. + HOME: /root + # The highest libc6 version the .deb is allowed to require. + MAX_GLIBC: '2.35' + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + apt-get update -qq + # rusqlite's bundled-sqlcipher-vendored-openssl and keyring's vendored + # feature compile SQLCipher, OpenSSL and libdbus from source, so a C + # toolchain plus perl covers them and no -dev packages are needed. + # winit and glutin dlopen the whole display stack, so there are no X11 + # or Wayland headers here either. The rest is what build-deb.sh checks + # for before it will run. + apt-get install -y --no-install-recommends \ + build-essential perl pkg-config \ + binutils dpkg-dev desktop-file-utils gzip + + - name: Trust the workspace + # checkout writes as root into a directory git then considers dubiously + # owned; build-deb.sh and the version scrape both shell out to git. + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Rust + run: | + # --default-toolchain none defers to rust-toolchain.toml, so the pinned + # channel and its targets are downloaded exactly once. + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # Materialise the pinned toolchain here rather than partway through the + # build, so a toolchain problem shows up as its own failed step. + "$HOME/.cargo/bin/rustup" show + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + # Keyed per job so this tree never collides with the cross-compiled one. + key: ${{ github.job }}-cargo-${{ hashFiles('**/Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: ${{ github.job }}-cargo- + + - name: Build + run: cargo build --release --locked -p quicksearch-gui + + - name: Test + # Release mode is not a nicety: tests/encrypted.rs derives an Argon2id key + # at m=64 MiB, t=3, which takes about half a second in release and minutes + # in debug. tests/snippet_perf.rs self-skips without QSB_SNIPPET_PERF=1. + run: cargo test --release --locked --workspace + + - name: Build the .deb + # --no-build reuses the binaries from the Build step rather than + # recompiling. SOURCE_DATE_EPOCH pins the generated changelog date so + # repeat builds of the same commit are byte-identical. + run: | + SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)" \ + ./packaging/build-deb.sh --no-build + + - name: Check the glibc floor + # The whole point of pinning the container. If someone bumps the image, + # this fails loudly instead of quietly shipping an uninstallable package. + run: | + deb=$(ls dist/*.deb) + depends=$(dpkg-deb -f "$deb" Depends) + echo "$depends" + floor=$(printf '%s' "$depends" | sed -n 's/.*libc6 (>= \([0-9][0-9.]*\)).*/\1/p') + [ -n "$floor" ] || { echo "could not read the libc6 floor from $deb" >&2; exit 1; } + # dpkg's own comparator, so 2.9 does not sort above 2.35. + if ! dpkg --compare-versions "$floor" le "$MAX_GLIBC"; then + echo "ERROR: the .deb requires glibc $floor, above the $MAX_GLIBC target." >&2 + echo "The build container base has probably changed." >&2 + exit 1 + fi + echo "OK: glibc floor $floor <= $MAX_GLIBC" + + - name: Package the binaries + # A tarball for anyone not installing the .deb, stripped to match what + # build-deb.sh ships. + run: | + version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) + [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } + stage="quicksearch-$version-linux-x86_64" + mkdir -p "dist/$stage" + for bin in quicksearch quicksearch-cli; do + install -m755 "target/release/$bin" "dist/$stage/$bin" + strip --strip-unneeded "dist/$stage/$bin" + done + install -m644 README.md config_example.toml LICENSE "dist/$stage/" + tar -czf "dist/$stage.tar.gz" -C dist "$stage" + rm -rf "dist/$stage" + ls -l dist/ + + - uses: actions/upload-artifact@v4 + with: + name: linux-x86_64 + path: | + dist/*.deb + dist/*.tar.gz + if-no-files-found: error + retention-days: 14 + + # -------------------------------------------------------- windows-cross ---- + # + # Deliberately a newer base than the linux job. This job emits a PE binary + # linked against msvcrt.dll, so the container's glibc cannot affect what the + # .exe runs on; pinning it to 22.04 would buy nothing while forcing the build + # through mingw-w64 10.3 instead of 13.2. + windows-cross: + runs-on: forgejo-runner + container: + image: catthehacker/ubuntu:act-24.04 + env: + HOME: /root + TARGET: x86_64-pc-windows-gnu + # rusqlite, zstd-sys and openssl-src all shell out to a C compiler, which + # has to be the cross one rather than the host's cc. + CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc + CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc + AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar + steps: + - uses: actions/checkout@v4 + + - name: Install build dependencies + run: | + apt-get update -qq + apt-get install -y --no-install-recommends \ + build-essential perl make pkg-config \ + gcc-mingw-w64-x86-64 zip + + - name: Trust the workspace + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain none + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + # Materialise the pinned toolchain here rather than partway through the + # build, so a toolchain problem shows up as its own failed step. + "$HOME/.cargo/bin/rustup" show + + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ github.job }}-cargo-${{ hashFiles('**/Cargo.lock', 'rust-toolchain.toml') }} + restore-keys: ${{ github.job }}-cargo- + + - name: Build + # rust-toolchain.toml already lists the target, so no `rustup target add`. + run: cargo build --release --locked -p quicksearch-gui --target "$TARGET" + + - name: Check for non-system DLL dependencies + # Debian's default mingw alternative uses posix threads, which can pull + # in libwinpthread-1.dll or libgcc_s_seh-1.dll and produce an .exe that + # refuses to start on a clean Windows machine. The binaries are currently + # clean - every import is a system DLL - and this keeps them that way. + run: | + for exe in "target/$TARGET"/release/quicksearch.exe "target/$TARGET"/release/quicksearch-cli.exe; do + dlls=$(x86_64-w64-mingw32-objdump -p "$exe" | sed -n 's/^[[:space:]]*DLL Name: //p' | sort -fu) + printf '%s:\n%s\n\n' "$exe" "$dlls" + if printf '%s\n' "$dlls" | grep -qiE '^(libgcc|libwinpthread|libstdc\+\+|libssp)'; then + echo "ERROR: $exe imports a non-system DLL and will not run on a clean Windows install." >&2 + exit 1 + fi + done + + - name: Package the binaries + run: | + version=$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' Cargo.toml) + [ -n "$version" ] || { echo "could not read the version from Cargo.toml" >&2; exit 1; } + stage="quicksearch-$version-windows-x86_64" + mkdir -p "dist/$stage" + for exe in quicksearch.exe quicksearch-cli.exe; do + install -m755 "target/$TARGET/release/$exe" "dist/$stage/$exe" + x86_64-w64-mingw32-strip --strip-unneeded "dist/$stage/$exe" + done + install -m644 README.md config_example.toml LICENSE "dist/$stage/" + (cd dist && zip -qr "$stage.zip" "$stage") + rm -rf "dist/$stage" + ls -l dist/ + + - uses: actions/upload-artifact@v4 + with: + name: windows-x86_64 + path: dist/*.zip + if-no-files-found: error + retention-days: 14 + + # -------------------------------------------------------------- release ---- + release: + needs: [linux, windows-cross] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: forgejo-runner + container: + image: catthehacker/ubuntu:act-24.04 + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Publish the release + # Forgejo populates GITHUB_API_URL, GITHUB_REPOSITORY and the token by + # itself, so this needs no configuration. Talking to the API directly + # keeps the release step off any third-party action's release cadence. + env: + RELEASE_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + api="${GITHUB_API_URL:-$GITHUB_SERVER_URL/api/v1}" + tag="${GITHUB_REF#refs/tags/}" + auth="Authorization: token $RELEASE_TOKEN" + + mkdir -p dist + find artifacts -type f -exec mv -t dist -- {} + + ls -l dist/ + + # Reuse the release when it already exists, so re-running a tag build + # replaces assets instead of failing. + id=$(curl -sS -H "$auth" "$api/repos/$GITHUB_REPOSITORY/releases/tags/$tag" | jq -r '.id // empty') + if [ -z "$id" ]; then + id=$(curl -fsS -X POST "$api/repos/$GITHUB_REPOSITORY/releases" \ + -H "$auth" -H 'Content-Type: application/json' \ + -d "$(jq -n --arg t "$tag" '{tag_name: $t, name: $t, draft: false, prerelease: false}')" \ + | jq -r '.id // empty') + fi + [ -n "$id" ] || { echo "could not create or find a release for $tag" >&2; exit 1; } + + for f in dist/*; do + name=$(basename "$f") + echo "uploading $name" + # Replace an asset of the same name left by an earlier run. + old=$(curl -sS -H "$auth" "$api/repos/$GITHUB_REPOSITORY/releases/$id/assets" \ + | jq -r --arg n "$name" '.[] | select(.name == $n) | .id') + [ -z "$old" ] || curl -fsS -X DELETE -H "$auth" \ + "$api/repos/$GITHUB_REPOSITORY/releases/$id/assets/$old" + curl -fsS -X POST "$api/repos/$GITHUB_REPOSITORY/releases/$id/assets?name=$name" \ + -H "$auth" -F "attachment=@$f" -o /dev/null + done + echo "published $tag" diff --git a/Cargo.toml b/Cargo.toml index b239e05..4738cbc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ ] [workspace.package] -version = "0.8.8" +version = "0.9.0" edition = "2021" license = "GPL-3.0-or-later" authors = ["Jeremy "] diff --git a/README.md b/README.md index b8c871f..fb2e945 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ Rust toolchain with rustup; `build.bat` uses winget and rustup. Both take `--check` to report dependency status without installing or building, `--no-run` to stop after the build, and `--` to pass the rest to the binary. -Building by hand needs a Rust toolchain (edition 2021) plus, on every -platform, a C toolchain and Perl: SQLCipher, zstd and OpenSSL are compiled -from bundled C sources, and OpenSSL's `Configure` is a Perl script. The old +Building by hand needs a Rust toolchain plus, on every platform, a C toolchain +and Perl: SQLCipher, zstd and OpenSSL are compiled from bundled C sources, and +OpenSSL's `Configure` is a Perl script. `rust-toolchain.toml` pins the compiler +version and the cross-compilation targets, so rustup installs the right ones on +the first `cargo` command and no `rustup target add` is needed. The old WebKit/WebView dependencies (`setup.sh`) are gone; the GUI renders with OpenGL via egui. @@ -35,9 +37,11 @@ OpenGL via egui. run time, so only the runtime libraries matter. - Windows: Visual Studio 2022 Build Tools with the "Desktop development with C++" workload (MSVC v143 plus a Windows SDK), and Perl (Strawberry - Perl); NASM is optional and only enables OpenSSL's assembly paths. For the - GNU target instead, `rustup target add x86_64-pc-windows-gnu` and a - mingw-w64 toolchain. Note that Windows ships only a software OpenGL 1.1 + Perl); NASM is optional and only enables OpenSSL's assembly paths. The GNU + target needs only a mingw-w64 toolchain, and cross-compiles from Linux — + `cargo build --release -p quicksearch-gui --target x86_64-pc-windows-gnu` + with `gcc-mingw-w64-x86-64` installed, which is how CI produces the Windows + binaries. Note that Windows ships only a software OpenGL 1.1 driver, so a bare VM or an RDP session without a vendor GPU driver cannot create a context and the window will fail to open. - macOS: Xcode command line tools (`build.sh` does not auto-install these — @@ -382,6 +386,14 @@ pagination: the table is virtualized, so a single scroll list capped at - `cargo test -p quicksearch-gui`: formatter/tracker/CLI-parsing units. - `QSB_SNIPPET_PERF=1 cargo test --release -p quicksearch-core --test snippet_perf -- --nocapture`: snippet pipeline benchmark. +- `.forgejo/workflows/ci.yml`: builds both platforms on every push to `master` + and every pull request, and attaches the `.deb`, a Linux tarball and a + Windows zip to a release on a `v*` tag. The Linux job runs in an Ubuntu + 22.04 container on purpose — `packaging/build-deb.sh` reads the package's + `libc6` floor from the binary it just built, so the builder's glibc becomes + the package's minimum, and 22.04 pins it at 2.35. The Windows job + cross-compiles with mingw-w64 and fails if either `.exe` picks up a + dependency on a non-system DLL. - New extractors: implement `extract::Extractor` and register it in `Registry::default_set()` — order matters, the first extractor whose `supports` accepts a MIME wins. New cascade behavior: `search/cascade.rs` diff --git a/config_example.toml b/config_example.toml index 538c469..819e8c7 100644 --- a/config_example.toml +++ b/config_example.toml @@ -54,9 +54,11 @@ include_hidden = false content_extensions = [] # Excluded from the index entirely. A pattern without a separator matches # any single path component (so ".git" prunes whole subtrees); patterns -# containing one match full paths. Glob syntax (*, ?, [..]). Matching is -# case-insensitive on Windows and macOS, case-sensitive elsewhere, -# following the filesystem. +# containing one match full paths, including a bare drive root like 'D:\'. +# Glob syntax (*, ?, [..]). A name pattern must match the whole name: +# ".jpg" only matches something named exactly ".jpg" — ignoring an +# extension needs the wildcard, "*.jpg". Matching is case-insensitive on +# Windows and macOS, case-sensitive elsewhere, following the filesystem. # # The Windows defaults add: "$RECYCLE.BIN", "System Volume Information", # "pagefile.sys", "hiberfil.sys", "swapfile.sys", "Thumbs.db", diff --git a/crates/quicksearch-core/examples/indexprobe.rs b/crates/quicksearch-core/examples/indexprobe.rs index 0661e5a..c496527 100644 --- a/crates/quicksearch-core/examples/indexprobe.rs +++ b/crates/quicksearch-core/examples/indexprobe.rs @@ -50,11 +50,42 @@ const LARGE_TEXT: usize = 100; const BINARY: usize = 100; const WORDS: &[&str] = &[ - "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", - "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "indexer", - "rust", "cargo", "sqlite", "baloo", "tokenizer", "trigram", "snippet", - "ocean", "forest", "mountain", "river", "valley", "bridge", "tunnel", - "morning", "afternoon", "evening", "midnight", "yesterday", "today", + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "theta", + "quick", + "brown", + "fox", + "jumps", + "over", + "lazy", + "dog", + "indexer", + "rust", + "cargo", + "sqlite", + "baloo", + "tokenizer", + "trigram", + "snippet", + "ocean", + "forest", + "mountain", + "river", + "valley", + "bridge", + "tunnel", + "morning", + "afternoon", + "evening", + "midnight", + "yesterday", + "today", ]; /// Deterministic so two runs index byte-identical trees and their timings are @@ -63,7 +94,10 @@ struct Rng(u64); impl Rng { fn next(&mut self) -> u64 { - self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); self.0 >> 33 } @@ -74,12 +108,20 @@ impl Rng { fn main() { let mode = std::env::args().nth(1).unwrap_or_default(); - let tree = PathBuf::from(std::env::args().nth(2).expect("usage: indexprobe [db]")); + let tree = PathBuf::from( + std::env::args() + .nth(2) + .expect("usage: indexprobe [db]"), + ); match mode.as_str() { "gen" => generate(&tree), "cold" | "warm" => { - let db = PathBuf::from(std::env::args().nth(3).expect("usage: indexprobe ")); + let db = PathBuf::from( + std::env::args() + .nth(3) + .expect("usage: indexprobe "), + ); if mode == "cold" { for suffix in ["", "-wal", "-shm"] { let _ = std::fs::remove_file(format!("{}{}", db.display(), suffix)); diff --git a/crates/quicksearch-core/examples/memprobe.rs b/crates/quicksearch-core/examples/memprobe.rs index 6b78693..dacf2ec 100644 --- a/crates/quicksearch-core/examples/memprobe.rs +++ b/crates/quicksearch-core/examples/memprobe.rs @@ -202,7 +202,9 @@ fn run(mode: &str, root: &str, db: &Path, interval: Duration) { assert!(done, "indexing did not finish within {:?}", TIMEOUT); service.stop_indexing().expect("stop"); - report(mode, elapsed, baseline, hwm, &samples, db, interval, &at_peak); + report( + mode, elapsed, baseline, hwm, &samples, db, interval, &at_peak, + ); } /// Flatten per-root progress into one line's worth of numbers. Roots are @@ -242,7 +244,10 @@ fn report( ) { // One line per 5% of the run, so the shape is visible at any duration. let step = (samples.len() / 20).max(1); - eprintln!("\n {:>8} {:>10} {:>9} {:>10} {}", "t", "RSS", "walked", "extracted", "phase"); + eprintln!( + "\n {:>8} {:>10} {:>9} {:>10} {}", + "t", "RSS", "walked", "extracted", "phase" + ); for s in samples.iter().step_by(step) { eprintln!( " {:>7.1}s {:>10} {:>9} {:>10} {}", @@ -260,7 +265,12 @@ fn report( let files = samples.iter().map(|s| s.walked).max().unwrap_or(0); let db_bytes = db_size(db); - eprintln!("\n{} run: {:.1}s, {} files walked", mode, elapsed.as_secs_f64(), files); + eprintln!( + "\n{} run: {:.1}s, {} files walked", + mode, + elapsed.as_secs_f64(), + files + ); match hwm { Some(h) => eprintln!(" peak RSS (VmHWM) {}", mib(h)), None => eprintln!(" peak RSS (VmHWM) unavailable"), @@ -289,7 +299,10 @@ fn report( ); } } - eprintln!(" database on disk {} (counts toward RSS as page cache)", mib(db_bytes)); + eprintln!( + " database on disk {} (counts toward RSS as page cache)", + mib(db_bytes) + ); if !at_peak.entries.is_empty() { eprintln!("\n resident bytes at the peak, by mapping:"); @@ -308,7 +321,10 @@ fn report( .collect(); jumps.sort_by_key(|(delta, _)| std::cmp::Reverse(*delta)); if !jumps.is_empty() { - eprintln!("\n largest RSS rises between samples ({:?} apart):", interval); + eprintln!( + "\n largest RSS rises between samples ({:?} apart):", + interval + ); for (delta, s) in jumps.iter().take(8) { eprintln!( " +{:>9} to {:>10} at t={:>6.1}s {} {}", diff --git a/crates/quicksearch-core/examples/walkprobe.rs b/crates/quicksearch-core/examples/walkprobe.rs index 434594b..2c193ff 100644 --- a/crates/quicksearch-core/examples/walkprobe.rs +++ b/crates/quicksearch-core/examples/walkprobe.rs @@ -49,7 +49,10 @@ fn main() { // Phase 1 in isolation: an empty index, so every file classifies as new. // The parallel walker reads its classification data from a database now, // so it gets a scratch one rather than an empty map. - let db = std::env::temp_dir().join(format!("quicksearch-walkprobe-{}.sqlite", std::process::id())); + let db = std::env::temp_dir().join(format!( + "quicksearch-walkprobe-{}.sqlite", + std::process::id() + )); let _ = std::fs::remove_file(&db); quicksearch_core::db::open_or_recreate(db.to_str().unwrap(), &config.processing.tokenize) .expect("scratch index"); @@ -122,7 +125,9 @@ fn parallel(root: &str, config: &Config, db_path: &str) -> (usize, usize) { Arc::new(AtomicBool::new(false)), 4, ) { - let WalkEvent::File(file) = event else { continue }; + let WalkEvent::File(file) = event else { + continue; + }; seen += 1; if file.record.is_some() { prepared += 1; diff --git a/crates/quicksearch-core/src/cli.rs b/crates/quicksearch-core/src/cli.rs index c1f2221..03306a4 100644 --- a/crates/quicksearch-core/src/cli.rs +++ b/crates/quicksearch-core/src/cli.rs @@ -144,9 +144,7 @@ pub fn list_failed(db_path: &str, limit: Option) -> Result /// Return a rough size breakdown of the database on disk and by table. pub fn index_size_breakdown(db_path: &str) -> Result { - let file_size_bytes = std::fs::metadata(db_path) - .map(|m| m.len()) - .unwrap_or(0); + let file_size_bytes = std::fs::metadata(db_path).map(|m| m.len()).unwrap_or(0); let conn = open_existing(db_path, false)?; let count = |table: &str| -> Result { conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |r| r.get(0)) @@ -384,8 +382,12 @@ mod tests { let ratio = r.documents_text_ratio().expect("has rows"); // Repeating a 44-byte sentence 500x → zstd should hit <20% ratio // trivially. Loose bound protects the test from zstd version churn. - assert!(ratio < 0.3, "ratio too high: {ratio} raw={} comp={}", - r.documents_text_raw_bytes, r.documents_text_compressed_bytes); + assert!( + ratio < 0.3, + "ratio too high: {ratio} raw={} comp={}", + r.documents_text_raw_bytes, + r.documents_text_compressed_bytes + ); std::fs::remove_file(&p).ok(); } diff --git a/crates/quicksearch-core/src/config.rs b/crates/quicksearch-core/src/config.rs index 57e64c0..a17819e 100644 --- a/crates/quicksearch-core/src/config.rs +++ b/crates/quicksearch-core/src/config.rs @@ -253,9 +253,11 @@ impl SecurityConfig { /// around. pub fn salt_bytes(&self) -> Result<[u8; crate::security::SALT_LEN], String> { match &self.salt { - None => Err("password protection is enabled but the config has no salt; \ + None => Err( + "password protection is enabled but the config has no salt; \ disable protection or set the password again" - .to_string()), + .to_string(), + ), Some(hex) => crate::security::salt_from_hex(hex) .map_err(|e| format!("invalid salt in config: {}", e)), } @@ -457,10 +459,7 @@ impl Config { /// location), creating parent directories as needed. Raw values are /// written verbatim — relative paths in a portable config stay relative. pub fn save(&self) -> Result<(), String> { - let path = self - .source - .clone() - .unwrap_or_else(Self::config_path); + let path = self.source.clone().unwrap_or_else(Self::config_path); if let Some(dir) = path.parent() { fs::create_dir_all(dir) .map_err(|e| format!("Failed to create config dir {}: {}", dir.display(), e))?; @@ -566,8 +565,25 @@ impl IgnoreSet { let mut path = globset::GlobSetBuilder::new(); for pat in patterns { // Trailing separators are how people naturally write directory - // patterns ("/tmp/"); paths compare without them, so strip. - let pat = pat.trim().trim_end_matches(['/', '\\']); + // patterns ("/tmp/"); paths compare without them, so strip — + // except a drive root ("D:\" or "D:/"), where the separator is + // the whole point: trimmed to "D:" it would become a component + // pattern that can never match. Drive roots keep a normalized + // "D:/" spelling, which the ancestor walk in + // `matches_path_pattern` does reach. + let raw = pat.trim(); + let trimmed = raw.trim_end_matches(['/', '\\']); + let is_drive_root = raw.len() > trimmed.len() + && trimmed.len() == 2 + && trimmed.as_bytes()[0].is_ascii_alphabetic() + && trimmed.as_bytes()[1] == b':'; + let drive_root; + let pat: &str = if is_drive_root { + drive_root = format!("{}/", trimmed); + &drive_root + } else { + trimmed + }; if pat.is_empty() { continue; } @@ -781,7 +797,11 @@ mod tests { fn partial_file_gets_section_defaults() { let dir = tmp_dir(); let path = dir.join("config.toml"); - fs::write(&path, "[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n").unwrap(); + fs::write( + &path, + "[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n", + ) + .unwrap(); let cfg = Config::load_from(&path).unwrap(); assert_eq!(cfg.paths.indexing_paths, vec!["/x".to_string()]); assert_eq!(cfg.processing.batch_size, 500, "missing sections default"); @@ -844,10 +864,16 @@ mod tests { cfg.indexing.content_extensions = vec!["txt".into(), ".MD".into()]; assert!(content_allowed(Path::new("/a/b.txt"), &cfg)); assert!(content_allowed(Path::new("/a/B.TXT"), &cfg)); - assert!(content_allowed(Path::new("/a/readme.md"), &cfg), "leading dot + case in filter"); + assert!( + content_allowed(Path::new("/a/readme.md"), &cfg), + "leading dot + case in filter" + ); assert!(!content_allowed(Path::new("/a/b.pdf"), &cfg)); assert!(!content_allowed(Path::new("/a/noext"), &cfg)); - assert!(!content_allowed(Path::new("/a/.bashrc"), &cfg), "dot-only name has no ext"); + assert!( + !content_allowed(Path::new("/a/.bashrc"), &cfg), + "dot-only name has no ext" + ); } #[test] @@ -855,9 +881,18 @@ mod tests { let mut cfg = Config::default(); cfg.indexing.content_extensions = vec!["txt".into(), " (NonE) ".into()]; assert!(content_allowed(Path::new("/a/Makefile"), &cfg)); - assert!(content_allowed(Path::new("/a/.bashrc"), &cfg), "dot-only name"); - assert!(content_allowed(Path::new("/a/b.txt"), &cfg), "real extensions still work"); - assert!(!content_allowed(Path::new("/a/b.pdf"), &cfg), "sentinel is not a wildcard"); + assert!( + content_allowed(Path::new("/a/.bashrc"), &cfg), + "dot-only name" + ); + assert!( + content_allowed(Path::new("/a/b.txt"), &cfg), + "real extensions still work" + ); + assert!( + !content_allowed(Path::new("/a/b.pdf"), &cfg), + "sentinel is not a wildcard" + ); // The sentinel is not itself an extension: a file literally named // `x.none` is not whitelisted by it. assert!(!content_allowed(Path::new("/a/x.none"), &cfg)); @@ -888,8 +923,14 @@ mod tests { "(none) # Makefile, LICENSE, ...".into(), ]; assert!(content_allowed(Path::new("/a/b.rs"), &cfg)); - assert!(content_allowed(Path::new("/a/b.md"), &cfg), "dot + trailing comment"); - assert!(content_allowed(Path::new("/a/Makefile"), &cfg), "sentinel + comment"); + assert!( + content_allowed(Path::new("/a/b.md"), &cfg), + "dot + trailing comment" + ); + assert!( + content_allowed(Path::new("/a/Makefile"), &cfg), + "sentinel + comment" + ); assert!(!content_allowed(Path::new("/a/b.pdf"), &cfg)); // Comment text is not itself a filter entry. assert!(!content_allowed(Path::new("/a/b.rust"), &cfg)); @@ -950,10 +991,10 @@ mod tests { #[test] fn directory_patterns_with_trailing_slash() { let set = IgnoreSet::compile(&[ - "/tmp/".to_string(), // absolute dir, natural spelling - "cache/".to_string(), // becomes a component pattern - "*/target/".to_string(), // dir anywhere by suffix - "/".to_string(), // degenerate: trims to nothing, skipped + "/tmp/".to_string(), // absolute dir, natural spelling + "cache/".to_string(), // becomes a component pattern + "*/target/".to_string(), // dir anywhere by suffix + "/".to_string(), // degenerate: trims to nothing, skipped ]) .unwrap(); // The directory itself and everything beneath it. @@ -968,6 +1009,41 @@ mod tests { assert!(!set.matches_path(Path::new("/etc/passwd"))); } + /// A drive-root pattern must survive the trailing-separator trim as a + /// path pattern — trimmed to "D:" it would land in the component set, + /// where nothing is ever named "D:". + #[test] + fn drive_root_patterns_are_not_component_patterns() { + let set = IgnoreSet::compile(&[r"D:\".to_string(), "E:/".to_string()]).unwrap(); + assert!(!set.matches_component("D:")); + assert!(!set.matches_component(r"D:\")); + assert!(!set.matches_component("E:")); + } + + /// The full drive-root behavior needs Windows path semantics: + /// `Path::parent` only walks up to `D:\` there, and globset only folds + /// `\` to `/` where `\` is a separator. + #[cfg(windows)] + #[test] + fn drive_root_pattern_ignores_the_whole_drive() { + let set = IgnoreSet::compile(&[r"D:\".to_string()]).unwrap(); + assert!(set.matches_path(Path::new(r"D:\"))); + assert!(set.matches_path(Path::new(r"D:\Users\x\file.txt"))); + assert!(set.matches_path(Path::new(r"d:\case\folded.txt"))); + assert!(!set.matches_path(Path::new(r"E:\file.txt"))); + } + + /// A bare "D:" (no separator) compiles but can only match a component + /// literally named "D:", which no file ever is. The GUI warns about + /// this shape; the compiler intentionally leaves it alone. + #[test] + fn bare_drive_letter_stays_a_component_pattern() { + let set = IgnoreSet::compile(&["D:".to_string()]).unwrap(); + assert!(set.matches_component("D:")); + #[cfg(windows)] + assert!(!set.matches_path(Path::new(r"D:\file.txt"))); + } + #[test] fn ignore_set_invalid_pattern_errors() { let err = IgnoreSet::compile(&["[".to_string()]).unwrap_err(); @@ -987,7 +1063,10 @@ mod tests { #[test] fn ignore_matching_follows_platform_case_rules() { let set = IgnoreSet::compile(&["node_modules".to_string()]).unwrap(); - assert!(set.matches_component("node_modules"), "exact always matches"); + assert!( + set.matches_component("node_modules"), + "exact always matches" + ); let folded = cfg!(any(windows, target_os = "macos")); assert_eq!( @@ -1080,7 +1159,11 @@ mod tests { cfg.save().unwrap(); let loaded = Config::load_from(&path).unwrap(); assert_eq!(loaded.indexing.root_workers.get("/share"), Some(&24)); - assert_eq!(loaded.indexing.root_workers.get("/data"), None, "absent = auto"); + assert_eq!( + loaded.indexing.root_workers.get("/data"), + None, + "absent = auto" + ); fs::remove_dir_all(&dir).ok(); } @@ -1305,11 +1388,17 @@ mod tests { let mut cfg = SearchConfig::default(); for quiet in 0..=FUZZY_EDITS_WARN_ABOVE { cfg.fuzzy_max_edits = quiet; - assert!(cfg.fuzzy_edits_warning().is_none(), "{} should be quiet", quiet); + assert!( + cfg.fuzzy_edits_warning().is_none(), + "{} should be quiet", + quiet + ); } for loud in [FUZZY_EDITS_WARN_ABOVE + 1, 8, usize::MAX] { cfg.fuzzy_max_edits = loud; - let msg = cfg.fuzzy_edits_warning().expect("warns above the threshold"); + let msg = cfg + .fuzzy_edits_warning() + .expect("warns above the threshold"); assert!(msg.contains(&loud.to_string())); assert!(msg.contains(&FUZZY_EDITS_WARN_ABOVE.to_string())); } diff --git a/crates/quicksearch-core/src/content.rs b/crates/quicksearch-core/src/content.rs index 1c5ed74..8b06f1f 100644 --- a/crates/quicksearch-core/src/content.rs +++ b/crates/quicksearch-core/src/content.rs @@ -223,19 +223,16 @@ fn feeder(shared: &Shared, db_path: &str, mut cursor: ExtractCursor, max_size: i }; while shared.take_feed_slot().is_some() { - let page = match crate::db::repo::pending_content_page( - &conn, - &cursor, - max_size, - FEED_PAGE as i64, - ) { - Ok(page) => page, - Err(e) => { - crate::log_warn!("{}", e); - shared.shutdown(); - return; - } - }; + let page = + match crate::db::repo::pending_content_page(&conn, &cursor, max_size, FEED_PAGE as i64) + { + Ok(page) => page, + Err(e) => { + crate::log_warn!("{}", e); + shared.shutdown(); + return; + } + }; let last_page = page.len() < FEED_PAGE; if let Some((id, _, _, _)) = page.last() { cursor.last_id = *id; @@ -318,7 +315,15 @@ pub fn extract_content( let stats = stats.clone(); thread::spawn(move || { crate::platform::set_background_priority(); - worker(&shared, &tx, ®istry, &config, &stop_flag, &suspend_flag, &stats) + worker( + &shared, + &tx, + ®istry, + &config, + &stop_flag, + &suspend_flag, + &stats, + ) }) }) .collect(); diff --git a/crates/quicksearch-core/src/coordinator.rs b/crates/quicksearch-core/src/coordinator.rs index 7025cb1..decfef8 100644 --- a/crates/quicksearch-core/src/coordinator.rs +++ b/crates/quicksearch-core/src/coordinator.rs @@ -209,7 +209,10 @@ impl IndexCoordinator { } /// Compare `config` against what the index was built with. Read-only. - pub fn check_config_validation(&self, config: &Config) -> Result>, String> { + pub fn check_config_validation( + &self, + config: &Config, + ) -> Result>, String> { let db = config.resolved_database_path(); let roots = joined_roots(config); self.indexing @@ -511,7 +514,10 @@ impl Inner { Ok(conn) => conn, Err(e) => { // Missing or stale DB: incremental can't help, rebuild. - crate::log_warn!("coordinator: incremental unavailable ({}); scheduling full run", e); + crate::log_warn!( + "coordinator: incremental unavailable ({}); scheduling full run", + e + ); self.needs_full_run = true; return; } @@ -609,7 +615,8 @@ impl Inner { for (child, parent) in &nested { crate::log_warn!( "coordinator: refusing to index: root {} is nested under {}", - child, parent + child, + parent ); } return; @@ -1012,9 +1019,11 @@ mod tests { // Periodic reindex is the fallback and must still run: mode stays // Auto, only the watcher is off. assert_eq!(coord.state().mode, IndexMode::Auto); - wait_for("full run despite no watcher", Duration::from_secs(20), || { - coord.state().last_full_index.is_some() && f.file_count() == 1 - }); + wait_for( + "full run despite no watcher", + Duration::from_secs(20), + || coord.state().last_full_index.is_some() && f.file_count() == 1, + ); coord.shutdown(); } @@ -1111,10 +1120,11 @@ mod tests { f.file_count() == 1 }); - let extra_root = f.dir.parent().unwrap().join(format!( - "qs-coord-extra-{}", - std::process::id() - )); + let extra_root = f + .dir + .parent() + .unwrap() + .join(format!("qs-coord-extra-{}", std::process::id())); std::fs::create_dir_all(&extra_root).unwrap(); std::fs::write(extra_root.join("second.txt"), "two").unwrap(); @@ -1147,7 +1157,10 @@ mod tests { &mut pending, FsEvent::Remove(dir.join(format!("sub{}/f{}.txt", i % 5, i))), ); - enqueue(&mut pending, FsEvent::Remove(dir.join(format!("sub{}", i % 5)))); + enqueue( + &mut pending, + FsEvent::Remove(dir.join(format!("sub{}", i % 5))), + ); } // Not under the removed tree, and not a removal: both must survive. enqueue(&mut pending, FsEvent::Remove(PathBuf::from("/x/treehouse"))); @@ -1200,9 +1213,11 @@ mod tests { }); std::fs::remove_dir_all(&tree).unwrap(); - wait_for("subtree removed from the index", Duration::from_secs(30), || { - f.file_count() == 1 - }); + wait_for( + "subtree removed from the index", + Duration::from_secs(30), + || f.file_count() == 1, + ); coord.shutdown(); } @@ -1234,9 +1249,11 @@ mod tests { std::fs::remove_dir_all(f.dir.join("d0")).unwrap(); // 300 files, 50 of them under d0. - wait_for("deletion applied after the run", Duration::from_secs(60), || { - f.file_count() == 250 - }); + wait_for( + "deletion applied after the run", + Duration::from_secs(60), + || f.file_count() == 250, + ); coord.shutdown(); } diff --git a/crates/quicksearch-core/src/db/key.rs b/crates/quicksearch-core/src/db/key.rs index 224c7e7..25825fc 100644 --- a/crates/quicksearch-core/src/db/key.rs +++ b/crates/quicksearch-core/src/db/key.rs @@ -22,7 +22,10 @@ pub fn set_process_key(key: Option) { /// Snapshot of the current key for a single open. pub(crate) fn process_key() -> Option { - PROCESS_KEY.read().expect("process key lock poisoned").clone() + PROCESS_KEY + .read() + .expect("process key lock poisoned") + .clone() } /// Hex form of the installed key, if any. Exists for exactly one consumer: diff --git a/crates/quicksearch-core/src/db/open.rs b/crates/quicksearch-core/src/db/open.rs index dc79aa4..b79640b 100644 --- a/crates/quicksearch-core/src/db/open.rs +++ b/crates/quicksearch-core/src/db/open.rs @@ -134,7 +134,11 @@ pub(crate) fn open_existing_keyed( write: bool, key: Option<&IndexKey>, ) -> Result { - let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY }; + let pragmas = if write { + PRAGMAS_FAST + } else { + PRAGMAS_READONLY + }; open_keyed_with_pragmas(db_path, write, key, pragmas) } @@ -295,8 +299,10 @@ fn key_mismatch_message(db_path: &str, had_key: bool) -> String { }) .unwrap_or(false); let detail = match (had_key, plaintext) { - (true, true) => "password protection is enabled but the index is not encrypted; \ - rebuild the index to encrypt it", + (true, true) => { + "password protection is enabled but the index is not encrypted; \ + rebuild the index to encrypt it" + } (true, false) => "wrong password (or the file is not a QuickSearch index)", (false, _) => "the index is password-protected; a password is required", }; @@ -709,12 +715,16 @@ mod tests { let p = tmp_db_path(); { let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); - let indexer: i64 = conn.query_row("PRAGMA temp_store", [], |r| r.get(0)).unwrap(); + let indexer: i64 = conn + .query_row("PRAGMA temp_store", [], |r| r.get(0)) + .unwrap(); assert_eq!(indexer, 2, "the indexer's own profile is MEMORY"); } let conn = open_maintenance(p.to_str().unwrap()).unwrap(); - let store: i64 = conn.query_row("PRAGMA temp_store", [], |r| r.get(0)).unwrap(); + let store: i64 = conn + .query_row("PRAGMA temp_store", [], |r| r.get(0)) + .unwrap(); assert_eq!(store, 1, "maintenance must build its temporaries on disk"); // And the directory those temporaries land in is steerable, which is @@ -726,7 +736,8 @@ mod tests { .query_row("PRAGMA temp_store_directory", [], |r| r.get(0)) .unwrap(); assert_eq!(set, dir); - conn.execute_batch("PRAGMA temp_store_directory = '';").unwrap(); + conn.execute_batch("PRAGMA temp_store_directory = '';") + .unwrap(); drop(conn); std::fs::remove_file(&p).ok(); @@ -754,8 +765,11 @@ mod tests { // Age it, exactly as a version bump does. { let conn = open_existing(p.to_str().unwrap(), true).unwrap(); - conn.execute("UPDATE schema_info SET value = '1' WHERE key = 'version'", []) - .unwrap(); + conn.execute( + "UPDATE schema_info SET value = '1' WHERE key = 'version'", + [], + ) + .unwrap(); } assert!(index_needs_rebuild(p.to_str().unwrap())); @@ -836,7 +850,7 @@ mod tests { conn.execute( "INSERT INTO files (name, path, parent, size, mtime) \ VALUES ('secret', '/secret.txt', '/', 0, 0)", - [], + [], ) .unwrap(); } @@ -877,8 +891,8 @@ mod tests { } let before = file_bytes(&p); for write in [false, true] { - let err = open_existing_keyed(p.to_str().unwrap(), write, Some(&test_key(0xb2))) - .unwrap_err(); + let err = + open_existing_keyed(p.to_str().unwrap(), write, Some(&test_key(0xb2))).unwrap_err(); assert!(err.starts_with(KEY_MISMATCH_PREFIX), "got: {err}"); } // The owner path must error too — a wrong key is never a "schema diff --git a/crates/quicksearch-core/src/db/repo.rs b/crates/quicksearch-core/src/db/repo.rs index f4bcf98..5d0a6c0 100644 --- a/crates/quicksearch-core/src/db/repo.rs +++ b/crates/quicksearch-core/src/db/repo.rs @@ -106,10 +106,7 @@ fn initial_content_state(f: &NewFile<'_>) -> i64 { /// `failure_msg` — and only those. `name`, `parent`, `inode` and `device_id` /// are not refreshed here despite being present on the `NewFile`: the row is /// found by path, and the first two are functions of it. -pub fn update_file_basic( - tx: &Transaction<'_>, - f: &NewFile<'_>, -) -> Result, String> { +pub fn update_file_basic(tx: &Transaction<'_>, f: &NewFile<'_>) -> Result, String> { // One statement, not a lookup then an update: `RETURNING` hands back the // id of the row it just wrote, and a miss is simply no row returned. let id: Option = tx @@ -210,11 +207,7 @@ pub fn set_content_done( const ZSTD_LEVEL: i32 = 3; /// Mark a file's content extraction as failed. Keeps the basic row in place. -pub fn set_content_failed( - tx: &Transaction<'_>, - file_id: i64, - reason: &str, -) -> Result<(), String> { +pub fn set_content_failed(tx: &Transaction<'_>, file_id: i64, reason: &str) -> Result<(), String> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs()) @@ -794,7 +787,9 @@ mod tests { row.mtime = 3; row.mime = Some("video/mp4"); row.needs_content = false; - update_file_basic(&tx, &row).unwrap().expect("row still there"); + update_file_basic(&tx, &row) + .unwrap() + .expect("row still there"); tx.commit().unwrap(); } assert_eq!(content_state(&conn), STATE_NA); @@ -910,7 +905,9 @@ mod tests { ); let survivors: Vec = { - let mut stmt = conn.prepare("SELECT path FROM files ORDER BY path").unwrap(); + let mut stmt = conn + .prepare("SELECT path FROM files ORDER BY path") + .unwrap(); let v = stmt .query_map([], |r| r.get::<_, String>(0)) .unwrap() @@ -1051,8 +1048,15 @@ mod tests { ) .unwrap() .unwrap(); - set_content_done(&tx, id, &name, &"lorem ipsum dolor sit amet ".repeat(64), &[], true) - .unwrap(); + set_content_done( + &tx, + id, + &name, + &"lorem ipsum dolor sit amet ".repeat(64), + &[], + true, + ) + .unwrap(); } tx.commit().unwrap(); } @@ -1193,7 +1197,10 @@ mod tests { let forced = run(&bounded, 4); std::fs::remove_file(&bounded).ok(); - eprintln!("peak WAL: autocheckpoint only {}, forced {}", left_alone, forced); + eprintln!( + "peak WAL: autocheckpoint only {}, forced {}", + left_alone, forced + ); assert!( forced * 2 < left_alone, "forcing checkpoints did not bound the log: {} vs {}", @@ -1226,8 +1233,15 @@ mod tests { assert!(freelist > 0, "the deletions should have freed pages"); let dir = p.parent().unwrap().to_string_lossy().into_owned(); - assert!(maintain(&conn, &dir).unwrap(), "that much slack is worth a vacuum"); - assert_eq!(wal_bytes(&p), 0, "the vacuum's own writes are checkpointed too"); + assert!( + maintain(&conn, &dir).unwrap(), + "that much slack is worth a vacuum" + ); + assert_eq!( + wal_bytes(&p), + 0, + "the vacuum's own writes are checkpointed too" + ); assert!( std::fs::metadata(&p).unwrap().len() < before, "the file should have shrunk" diff --git a/crates/quicksearch-core/src/db/schema.rs b/crates/quicksearch-core/src/db/schema.rs index 58628ad..6ab905f 100644 --- a/crates/quicksearch-core/src/db/schema.rs +++ b/crates/quicksearch-core/src/db/schema.rs @@ -203,8 +203,14 @@ mod tests { #[test] fn plain_trigram_gets_accent_stripping() { - assert_eq!(effective_tokenizer("trigram"), "trigram remove_diacritics 1"); - assert_eq!(effective_tokenizer(" trigram "), "trigram remove_diacritics 1"); + assert_eq!( + effective_tokenizer("trigram"), + "trigram remove_diacritics 1" + ); + assert_eq!( + effective_tokenizer(" trigram "), + "trigram remove_diacritics 1" + ); } #[test] diff --git a/crates/quicksearch-core/src/document_extraction.rs b/crates/quicksearch-core/src/document_extraction.rs index 926f3dd..e3d7eb5 100644 --- a/crates/quicksearch-core/src/document_extraction.rs +++ b/crates/quicksearch-core/src/document_extraction.rs @@ -1,364 +1,373 @@ -use std::ffi::OsString; -use std::fs::File; -use std::io::{Read, BufReader}; - -use zip::ZipArchive; -use quick_xml::Reader; -use quick_xml::events::Event; - -/// Extract text from DOCX files by parsing the word/document.xml -pub fn extract_text_from_docx(file_path: &OsString) -> Result> { - let file = File::open(file_path)?; - let mut archive = ZipArchive::new(BufReader::new(file))?; - - let mut document_xml = archive.by_name("word/document.xml")?; - let mut content = String::new(); - document_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - - let mut text_content = String::new(); - let mut buf = Vec::new(); - let mut in_text = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - if e.name().as_ref() == b"w:t" { - in_text = true; - } - } - Ok(Event::Text(e)) if in_text => { - text_content.push_str(&e.unescape()?.into_owned()); - } - Ok(Event::End(ref e)) => { - if e.name().as_ref() == b"w:t" { - in_text = false; - } else if e.name().as_ref() == b"w:p" { - text_content.push('\n'); - } - } - Ok(Event::Eof) => break, - Err(e) => return Err(format!("Error parsing XML: {}", e).into()), - _ => {} - } - buf.clear(); - } - - Ok(text_content) -} - -/// Extract text from XLSX files by parsing worksheet XML files -pub fn extract_text_from_xlsx(file_path: &OsString) -> Result> { - let file = File::open(file_path)?; - let mut archive = ZipArchive::new(BufReader::new(file))?; - - let mut text_content = String::new(); - - // First, read shared strings if they exist - let mut shared_strings = Vec::new(); - if let Ok(mut shared_strings_xml) = archive.by_name("xl/sharedStrings.xml") { - let mut content = String::new(); - shared_strings_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - let mut buf = Vec::new(); - let mut in_text = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - if e.name().as_ref() == b"t" { - in_text = true; - } - } - Ok(Event::Text(e)) if in_text => { - shared_strings.push(e.unescape()?.into_owned()); - } - Ok(Event::End(ref e)) => { - if e.name().as_ref() == b"t" { - in_text = false; - } - } - Ok(Event::Eof) => break, - _ => {} - } - buf.clear(); - } - } - - // Read worksheets - for i in 0..archive.len() { - let file_name = archive.by_index(i)?.name().to_string(); - if file_name.starts_with("xl/worksheets/sheet") && file_name.ends_with(".xml") { - let mut sheet_xml = archive.by_index(i)?; - let mut content = String::new(); - sheet_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - let mut buf = Vec::new(); - let mut in_cell = false; - let mut cell_type = String::new(); - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - if e.name().as_ref() == b"c" { - in_cell = true; - cell_type.clear(); - for attr in e.attributes() { - let attr = attr?; - if attr.key.as_ref() == b"t" { - cell_type = String::from_utf8_lossy(&attr.value).to_string(); - } - } - } else if e.name().as_ref() == b"v" && in_cell { - // Value element - } - } - Ok(Event::Text(e)) if in_cell => { - let text = e.unescape()?.into_owned(); - if cell_type == "s" { - // Shared string reference - if let Ok(index) = text.parse::() { - if index < shared_strings.len() { - text_content.push_str(&shared_strings[index]); - text_content.push(' '); - } - } - } else { - text_content.push_str(&text); - text_content.push(' '); - } - } - Ok(Event::End(ref e)) => { - if e.name().as_ref() == b"c" { - in_cell = false; - } else if e.name().as_ref() == b"row" { - text_content.push('\n'); - } - } - Ok(Event::Eof) => break, - _ => {} - } - buf.clear(); - } - } - } - - Ok(text_content) -} - -/// Extract text from PPTX files by parsing slide XML files -pub fn extract_text_from_pptx(file_path: &OsString) -> Result> { - let file = File::open(file_path)?; - let mut archive = ZipArchive::new(BufReader::new(file))?; - - let mut text_content = String::new(); - - // Read all slide files - for i in 0..archive.len() { - let file_name = archive.by_index(i)?.name().to_string(); - if file_name.starts_with("ppt/slides/slide") && file_name.ends_with(".xml") { - let mut slide_xml = archive.by_index(i)?; - let mut content = String::new(); - slide_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - let mut buf = Vec::new(); - let mut in_text = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - if e.name().as_ref() == b"a:t" { - in_text = true; - } - } - Ok(Event::Text(e)) if in_text => { - text_content.push_str(&e.unescape()?.into_owned()); - } - Ok(Event::End(ref e)) => { - if e.name().as_ref() == b"a:t" { - in_text = false; - } else if e.name().as_ref() == b"a:p" { - text_content.push('\n'); - } - } - Ok(Event::Eof) => break, - _ => {} - } - buf.clear(); - } - text_content.push_str("\n--- New Slide ---\n"); - } - } - - Ok(text_content) -} - -/// Extract text from ODT files (OpenDocument Text) -pub fn extract_text_from_odt(file_path: &OsString) -> Result> { - let file = File::open(file_path)?; - let mut archive = ZipArchive::new(BufReader::new(file))?; - - let mut content_xml = archive.by_name("content.xml")?; - let mut content = String::new(); - content_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - - let mut text_content = String::new(); - let mut buf = Vec::new(); - let mut in_text = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - let name = e.name(); - if name.as_ref() == b"text:p" || name.as_ref() == b"text:h" || name.as_ref() == b"text:span" { - in_text = true; - } - } - Ok(Event::Text(e)) if in_text => { - text_content.push_str(&e.unescape()?.into_owned()); - } - Ok(Event::End(ref e)) => { - let name = e.name(); - if name.as_ref() == b"text:p" || name.as_ref() == b"text:h" { - text_content.push('\n'); - in_text = false; - } else if name.as_ref() == b"text:span" { - in_text = false; - } - } - Ok(Event::Eof) => break, - _ => {} - } - buf.clear(); - } - - Ok(text_content) -} - -/// Extract text from ODP files (OpenDocument Presentation) -pub fn extract_text_from_odp(file_path: &OsString) -> Result> { - let file = File::open(file_path)?; - let mut archive = ZipArchive::new(BufReader::new(file))?; - - let mut content_xml = archive.by_name("content.xml")?; - let mut content = String::new(); - content_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - - let mut text_content = String::new(); - let mut buf = Vec::new(); - let mut in_text = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - let name = e.name(); - if name.as_ref() == b"text:p" || name.as_ref() == b"text:h" || name.as_ref() == b"text:span" { - in_text = true; - } - } - Ok(Event::Text(e)) if in_text => { - text_content.push_str(&e.unescape()?.into_owned()); - } - Ok(Event::End(ref e)) => { - let name = e.name(); - if name.as_ref() == b"text:p" || name.as_ref() == b"text:h" { - text_content.push('\n'); - in_text = false; - } else if name.as_ref() == b"text:span" { - in_text = false; - } - } - Ok(Event::Eof) => break, - _ => {} - } - buf.clear(); - } - - Ok(text_content) -} - -/// Extract text from ODS files (OpenDocument Spreadsheet) -pub fn extract_text_from_ods(file_path: &OsString) -> Result> { - let file = File::open(file_path)?; - let mut archive = ZipArchive::new(BufReader::new(file))?; - - let mut content_xml = archive.by_name("content.xml")?; - let mut content = String::new(); - content_xml.read_to_string(&mut content)?; - - let mut reader = Reader::from_str(&content); - reader.trim_text(true); - - let mut text_content = String::new(); - let mut buf = Vec::new(); - let mut in_text = false; - - loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(ref e)) => { - let name = e.name(); - if name.as_ref() == b"text:p" || name.as_ref() == b"text:span" { - in_text = true; - } - } - Ok(Event::Text(e)) if in_text => { - text_content.push_str(&e.unescape()?.into_owned()); - text_content.push(' '); - } - Ok(Event::End(ref e)) => { - let name = e.name(); - if name.as_ref() == b"text:p" { - text_content.push('\n'); - in_text = false; - } else if name.as_ref() == b"text:span" { - in_text = false; - } - } - Ok(Event::Eof) => break, - _ => {} - } - buf.clear(); - } - - Ok(text_content) -} - -/// Extract text from various document formats -pub fn extract_document_text(file_path: &OsString, extension: &str) -> Result> { - match extension { - "docx" => extract_text_from_docx(file_path), - "doc" => { - // DOC format is binary and complex to parse without external tools - // For now, return an empty result - Ok(String::new()) - } - "xlsx" => extract_text_from_xlsx(file_path), - "xls" => { - // XLS format is binary and complex to parse without external tools - Ok(String::new()) - } - "pptx" => extract_text_from_pptx(file_path), - "ppt" => { - // PPT format is binary and complex to parse without external tools - Ok(String::new()) - } - "odt" => extract_text_from_odt(file_path), - "odp" => extract_text_from_odp(file_path), - "ods" => extract_text_from_ods(file_path), - _ => Ok(String::new()) - } -} +use std::ffi::OsString; +use std::fs::File; +use std::io::{BufReader, Read}; + +use quick_xml::events::Event; +use quick_xml::Reader; +use zip::ZipArchive; + +/// Extract text from DOCX files by parsing the word/document.xml +pub fn extract_text_from_docx(file_path: &OsString) -> Result> { + let file = File::open(file_path)?; + let mut archive = ZipArchive::new(BufReader::new(file))?; + + let mut document_xml = archive.by_name("word/document.xml")?; + let mut content = String::new(); + document_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + + let mut text_content = String::new(); + let mut buf = Vec::new(); + let mut in_text = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + if e.name().as_ref() == b"w:t" { + in_text = true; + } + } + Ok(Event::Text(e)) if in_text => { + text_content.push_str(&e.unescape()?.into_owned()); + } + Ok(Event::End(ref e)) => { + if e.name().as_ref() == b"w:t" { + in_text = false; + } else if e.name().as_ref() == b"w:p" { + text_content.push('\n'); + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(format!("Error parsing XML: {}", e).into()), + _ => {} + } + buf.clear(); + } + + Ok(text_content) +} + +/// Extract text from XLSX files by parsing worksheet XML files +pub fn extract_text_from_xlsx(file_path: &OsString) -> Result> { + let file = File::open(file_path)?; + let mut archive = ZipArchive::new(BufReader::new(file))?; + + let mut text_content = String::new(); + + // First, read shared strings if they exist + let mut shared_strings = Vec::new(); + if let Ok(mut shared_strings_xml) = archive.by_name("xl/sharedStrings.xml") { + let mut content = String::new(); + shared_strings_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + let mut buf = Vec::new(); + let mut in_text = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + if e.name().as_ref() == b"t" { + in_text = true; + } + } + Ok(Event::Text(e)) if in_text => { + shared_strings.push(e.unescape()?.into_owned()); + } + Ok(Event::End(ref e)) => { + if e.name().as_ref() == b"t" { + in_text = false; + } + } + Ok(Event::Eof) => break, + _ => {} + } + buf.clear(); + } + } + + // Read worksheets + for i in 0..archive.len() { + let file_name = archive.by_index(i)?.name().to_string(); + if file_name.starts_with("xl/worksheets/sheet") && file_name.ends_with(".xml") { + let mut sheet_xml = archive.by_index(i)?; + let mut content = String::new(); + sheet_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + let mut buf = Vec::new(); + let mut in_cell = false; + let mut cell_type = String::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + if e.name().as_ref() == b"c" { + in_cell = true; + cell_type.clear(); + for attr in e.attributes() { + let attr = attr?; + if attr.key.as_ref() == b"t" { + cell_type = String::from_utf8_lossy(&attr.value).to_string(); + } + } + } else if e.name().as_ref() == b"v" && in_cell { + // Value element + } + } + Ok(Event::Text(e)) if in_cell => { + let text = e.unescape()?.into_owned(); + if cell_type == "s" { + // Shared string reference + if let Ok(index) = text.parse::() { + if index < shared_strings.len() { + text_content.push_str(&shared_strings[index]); + text_content.push(' '); + } + } + } else { + text_content.push_str(&text); + text_content.push(' '); + } + } + Ok(Event::End(ref e)) => { + if e.name().as_ref() == b"c" { + in_cell = false; + } else if e.name().as_ref() == b"row" { + text_content.push('\n'); + } + } + Ok(Event::Eof) => break, + _ => {} + } + buf.clear(); + } + } + } + + Ok(text_content) +} + +/// Extract text from PPTX files by parsing slide XML files +pub fn extract_text_from_pptx(file_path: &OsString) -> Result> { + let file = File::open(file_path)?; + let mut archive = ZipArchive::new(BufReader::new(file))?; + + let mut text_content = String::new(); + + // Read all slide files + for i in 0..archive.len() { + let file_name = archive.by_index(i)?.name().to_string(); + if file_name.starts_with("ppt/slides/slide") && file_name.ends_with(".xml") { + let mut slide_xml = archive.by_index(i)?; + let mut content = String::new(); + slide_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + let mut buf = Vec::new(); + let mut in_text = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + if e.name().as_ref() == b"a:t" { + in_text = true; + } + } + Ok(Event::Text(e)) if in_text => { + text_content.push_str(&e.unescape()?.into_owned()); + } + Ok(Event::End(ref e)) => { + if e.name().as_ref() == b"a:t" { + in_text = false; + } else if e.name().as_ref() == b"a:p" { + text_content.push('\n'); + } + } + Ok(Event::Eof) => break, + _ => {} + } + buf.clear(); + } + text_content.push_str("\n--- New Slide ---\n"); + } + } + + Ok(text_content) +} + +/// Extract text from ODT files (OpenDocument Text) +pub fn extract_text_from_odt(file_path: &OsString) -> Result> { + let file = File::open(file_path)?; + let mut archive = ZipArchive::new(BufReader::new(file))?; + + let mut content_xml = archive.by_name("content.xml")?; + let mut content = String::new(); + content_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + + let mut text_content = String::new(); + let mut buf = Vec::new(); + let mut in_text = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let name = e.name(); + if name.as_ref() == b"text:p" + || name.as_ref() == b"text:h" + || name.as_ref() == b"text:span" + { + in_text = true; + } + } + Ok(Event::Text(e)) if in_text => { + text_content.push_str(&e.unescape()?.into_owned()); + } + Ok(Event::End(ref e)) => { + let name = e.name(); + if name.as_ref() == b"text:p" || name.as_ref() == b"text:h" { + text_content.push('\n'); + in_text = false; + } else if name.as_ref() == b"text:span" { + in_text = false; + } + } + Ok(Event::Eof) => break, + _ => {} + } + buf.clear(); + } + + Ok(text_content) +} + +/// Extract text from ODP files (OpenDocument Presentation) +pub fn extract_text_from_odp(file_path: &OsString) -> Result> { + let file = File::open(file_path)?; + let mut archive = ZipArchive::new(BufReader::new(file))?; + + let mut content_xml = archive.by_name("content.xml")?; + let mut content = String::new(); + content_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + + let mut text_content = String::new(); + let mut buf = Vec::new(); + let mut in_text = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let name = e.name(); + if name.as_ref() == b"text:p" + || name.as_ref() == b"text:h" + || name.as_ref() == b"text:span" + { + in_text = true; + } + } + Ok(Event::Text(e)) if in_text => { + text_content.push_str(&e.unescape()?.into_owned()); + } + Ok(Event::End(ref e)) => { + let name = e.name(); + if name.as_ref() == b"text:p" || name.as_ref() == b"text:h" { + text_content.push('\n'); + in_text = false; + } else if name.as_ref() == b"text:span" { + in_text = false; + } + } + Ok(Event::Eof) => break, + _ => {} + } + buf.clear(); + } + + Ok(text_content) +} + +/// Extract text from ODS files (OpenDocument Spreadsheet) +pub fn extract_text_from_ods(file_path: &OsString) -> Result> { + let file = File::open(file_path)?; + let mut archive = ZipArchive::new(BufReader::new(file))?; + + let mut content_xml = archive.by_name("content.xml")?; + let mut content = String::new(); + content_xml.read_to_string(&mut content)?; + + let mut reader = Reader::from_str(&content); + reader.trim_text(true); + + let mut text_content = String::new(); + let mut buf = Vec::new(); + let mut in_text = false; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(ref e)) => { + let name = e.name(); + if name.as_ref() == b"text:p" || name.as_ref() == b"text:span" { + in_text = true; + } + } + Ok(Event::Text(e)) if in_text => { + text_content.push_str(&e.unescape()?.into_owned()); + text_content.push(' '); + } + Ok(Event::End(ref e)) => { + let name = e.name(); + if name.as_ref() == b"text:p" { + text_content.push('\n'); + in_text = false; + } else if name.as_ref() == b"text:span" { + in_text = false; + } + } + Ok(Event::Eof) => break, + _ => {} + } + buf.clear(); + } + + Ok(text_content) +} + +/// Extract text from various document formats +pub fn extract_document_text( + file_path: &OsString, + extension: &str, +) -> Result> { + match extension { + "docx" => extract_text_from_docx(file_path), + "doc" => { + // DOC format is binary and complex to parse without external tools + // For now, return an empty result + Ok(String::new()) + } + "xlsx" => extract_text_from_xlsx(file_path), + "xls" => { + // XLS format is binary and complex to parse without external tools + Ok(String::new()) + } + "pptx" => extract_text_from_pptx(file_path), + "ppt" => { + // PPT format is binary and complex to parse without external tools + Ok(String::new()) + } + "odt" => extract_text_from_odt(file_path), + "odp" => extract_text_from_odp(file_path), + "ods" => extract_text_from_ods(file_path), + _ => Ok(String::new()), + } +} diff --git a/crates/quicksearch-core/src/extract/image.rs b/crates/quicksearch-core/src/extract/image.rs index 9b0920d..cf4d2e2 100644 --- a/crates/quicksearch-core/src/extract/image.rs +++ b/crates/quicksearch-core/src/extract/image.rs @@ -18,8 +18,7 @@ impl Extractor for ImageExtractor { } fn extract(&self, path: &Path) -> Result { - let file = File::open(path) - .map_err(|e| format!("image open {}: {}", path.display(), e))?; + let file = File::open(path).map_err(|e| format!("image open {}: {}", path.display(), e))?; let mut bufreader = BufReader::new(&file); let mut out = ExtractedContent::default(); diff --git a/crates/quicksearch-core/src/extract/mod.rs b/crates/quicksearch-core/src/extract/mod.rs index 55de551..19f2eb5 100644 --- a/crates/quicksearch-core/src/extract/mod.rs +++ b/crates/quicksearch-core/src/extract/mod.rs @@ -114,7 +114,9 @@ pub struct Registry { impl Registry { pub fn new() -> Self { - Self { extractors: Vec::new() } + Self { + extractors: Vec::new(), + } } pub fn with(mut self, e: impl Extractor + 'static) -> Self { @@ -167,7 +169,8 @@ impl Registry { mime: &str, head: &[u8], ) -> Option> { - self.find(mime).and_then(|e| e.extract_from_head(path, head)) + self.find(mime) + .and_then(|e| e.extract_from_head(path, head)) } /// The default set: RTF, plaintext, office docs, PDF, audio tags, @@ -220,11 +223,17 @@ mod tests { // A format that seeks or reads a trailer must not be handed a buffer. // `None` here is what routes it back to the on-disk extractor. - assert!(r.extract_complete_head(p, "application/pdf", b"%PDF-1.4").is_none()); - assert!(r.extract_complete_head(p, "image/png", b"\x89PNG").is_none()); + assert!(r + .extract_complete_head(p, "application/pdf", b"%PDF-1.4") + .is_none()); + assert!(r + .extract_complete_head(p, "image/png", b"\x89PNG") + .is_none()); // No extractor claims the MIME at all. - assert!(r.extract_complete_head(p, "application/x-nonesuch", b"..").is_none()); + assert!(r + .extract_complete_head(p, "application/x-nonesuch", b"..") + .is_none()); } #[test] @@ -233,16 +242,23 @@ mod tests { // file's text would depend on which pass happened to handle it. let r = Registry::default_set(); let p = Path::new("/tmp/whatever"); - for mime in ["text/plain", "TEXT/PLAIN", "application/json", "application/x-sql"] { + for mime in [ + "text/plain", + "TEXT/PLAIN", + "application/json", + "application/x-sql", + ] { assert!( r.extract_complete_head(p, mime, b"x").is_some(), - "{} should extract from a head", mime + "{} should extract from a head", + mime ); } for mime in ["application/rtf", "text/rtf"] { assert!( r.extract_complete_head(p, mime, br"{\rtf1 x}").is_some(), - "{} should extract from a head", mime + "{} should extract from a head", + mime ); } } @@ -305,7 +321,10 @@ mod tests { .with_property("a", "1"); assert_eq!( c.properties_sorted(), - vec![("a".to_string(), "1".to_string()), ("b".to_string(), "2".to_string())] + vec![ + ("a".to_string(), "1".to_string()), + ("b".to_string(), "2".to_string()) + ] ); } } diff --git a/crates/quicksearch-core/src/extract/pdf.rs b/crates/quicksearch-core/src/extract/pdf.rs index 681c85a..e6da8e5 100644 --- a/crates/quicksearch-core/src/extract/pdf.rs +++ b/crates/quicksearch-core/src/extract/pdf.rs @@ -76,7 +76,9 @@ impl Extractor for PdfExtractor { if let Ok(info_id) = info_ref.as_reference() { if let Ok(info) = doc.get_object(info_id) { if let Ok(dict) = info.as_dict() { - for key in ["Title", "Author", "Subject", "Keywords", "Creator", "Producer"] { + for key in [ + "Title", "Author", "Subject", "Keywords", "Creator", "Producer", + ] { if let Ok(val) = dict.get(key.as_bytes()) { if let Some(s) = object_to_string(val) { if !s.is_empty() { diff --git a/crates/quicksearch-core/src/extract/plaintext.rs b/crates/quicksearch-core/src/extract/plaintext.rs index 553ca75..4760b57 100644 --- a/crates/quicksearch-core/src/extract/plaintext.rs +++ b/crates/quicksearch-core/src/extract/plaintext.rs @@ -78,8 +78,8 @@ impl Extractor for PlaintextExtractor { /// Neither case was ever atomic — a concurrent writer can tear a file /// across any read sequence, including `read_to_string`'s. fn extract(&self, path: &Path) -> Result { - let mut f = File::open(path) - .map_err(|e| format!("plaintext read {}: {}", path.display(), e))?; + let mut f = + File::open(path).map_err(|e| format!("plaintext read {}: {}", path.display(), e))?; let size = f .metadata() .map_err(|e| format!("plaintext read {}: {}", path.display(), e))? @@ -148,10 +148,16 @@ mod tests { #[test] fn head_extraction_matches_reading_the_file() { - let p = tmp("agree", b"shared body with unicode: caf\xc3\xa9 \xe2\x9c\x93"); + let p = tmp( + "agree", + b"shared body with unicode: caf\xc3\xa9 \xe2\x9c\x93", + ); let from_disk = PlaintextExtractor.extract(&p).unwrap(); let bytes = std::fs::read(&p).unwrap(); - let from_head = PlaintextExtractor.extract_from_head(&p, &bytes).unwrap().unwrap(); + let from_head = PlaintextExtractor + .extract_from_head(&p, &bytes) + .unwrap() + .unwrap(); assert_eq!(from_disk.text, from_head.text); assert_eq!(from_disk.properties, from_head.properties); std::fs::remove_file(&p).ok(); @@ -168,7 +174,11 @@ mod tests { .unwrap() .unwrap_err(); assert_eq!(disk_err, head_err, "one decode path, one message"); - assert!(disk_err.contains("binary"), "the failure names the file: {}", disk_err); + assert!( + disk_err.contains("binary"), + "the failure names the file: {}", + disk_err + ); std::fs::remove_file(&p).ok(); } @@ -177,7 +187,10 @@ mod tests { let body = b"une journ\xe9e agr\xe9able pr\xe8s de la rivi\xe8re"; let p = tmp("latin1", body); let from_disk = PlaintextExtractor.extract(&p).unwrap(); - let from_head = PlaintextExtractor.extract_from_head(&p, body).unwrap().unwrap(); + let from_head = PlaintextExtractor + .extract_from_head(&p, body) + .unwrap() + .unwrap(); assert_eq!(from_disk.text, from_head.text); assert_eq!(from_disk.text, "une journée agréable près de la rivière"); std::fs::remove_file(&p).ok(); @@ -190,9 +203,15 @@ mod tests { body.extend(src.encode_utf16().flat_map(|u| u.to_le_bytes())); let p = tmp("utf16", &body); let from_disk = PlaintextExtractor.extract(&p).unwrap(); - let from_head = PlaintextExtractor.extract_from_head(&p, &body).unwrap().unwrap(); + let from_head = PlaintextExtractor + .extract_from_head(&p, &body) + .unwrap() + .unwrap(); assert_eq!(from_disk.text, from_head.text); - assert_eq!(from_disk.text, src, "stored text is the UTF-8 decode, BOM stripped"); + assert_eq!( + from_disk.text, src, + "stored text is the UTF-8 decode, BOM stripped" + ); std::fs::remove_file(&p).ok(); } @@ -213,7 +232,11 @@ mod tests { let p = tmp("empty", b""); assert_eq!(PlaintextExtractor.extract(&p).unwrap().text, ""); assert_eq!( - PlaintextExtractor.extract_from_head(&p, &[]).unwrap().unwrap().text, + PlaintextExtractor + .extract_from_head(&p, &[]) + .unwrap() + .unwrap() + .text, "" ); std::fs::remove_file(&p).ok(); diff --git a/crates/quicksearch-core/src/extract/rtf.rs b/crates/quicksearch-core/src/extract/rtf.rs index 511439b..8c9c07b 100644 --- a/crates/quicksearch-core/src/extract/rtf.rs +++ b/crates/quicksearch-core/src/extract/rtf.rs @@ -37,8 +37,8 @@ impl Extractor for RtfExtractor { fn extract(&self, path: &Path) -> Result { // Plain read: RTF files are rare and small enough that plaintext's // sized-read syscall trimming would be tuning without a workload. - let bytes = std::fs::read(path) - .map_err(|e| format!("rtf read {}: {}", path.display(), e))?; + let bytes = + std::fs::read(path).map_err(|e| format!("rtf read {}: {}", path.display(), e))?; parse(bytes, path) } diff --git a/crates/quicksearch-core/src/file_handling.rs b/crates/quicksearch-core/src/file_handling.rs index 9d51b48..ee14f33 100644 --- a/crates/quicksearch-core/src/file_handling.rs +++ b/crates/quicksearch-core/src/file_handling.rs @@ -1,17 +1,17 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Mutex, Arc}; use std::fs::File; use std::io::Read; use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; // Only the Unix entry-count path shells out; Windows walks the tree directly. +use std::collections::HashMap; #[cfg(unix)] use std::process::{Command, Stdio}; use std::time::UNIX_EPOCH; -use std::collections::HashMap; -use sha2::{Sha256, Digest}; -use walkdir::{DirEntry, WalkDir}; use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use walkdir::{DirEntry, WalkDir}; use crate::config::{Config, IgnoreSet}; use crate::db::repo::{self, NewFile}; @@ -57,7 +57,10 @@ pub(crate) fn path_to_db_string(path: &Path) -> String { let s = path.to_string_lossy(); if let Some(rest) = s.strip_prefix(r"\\?\UNC\") { format!(r"\\{}", rest) - } else if let Some(rest) = s.strip_prefix(r"\\?\").filter(|r| starts_with_drive_letter(r)) { + } else if let Some(rest) = s + .strip_prefix(r"\\?\") + .filter(|r| starts_with_drive_letter(r)) + { rest.to_string() } else { s.into_owned() @@ -143,7 +146,6 @@ fn parent_str(path: &str) -> String { .unwrap_or_default() } - /// Paths a walk could not read, collected as it runs. /// /// A full run deletes index rows for everything it did not see, so "I could @@ -218,6 +220,12 @@ fn walk_filter(e: &DirEntry, include_hidden: bool, ignore: &IgnoreSet) -> bool { /// Directories that cannot be read are recorded in `failures` rather than /// silently skipped, so the caller can tell an unreadable subtree apart /// from a deleted one. +/// +/// Unlike the parallel walker's symlink resolution, entries here never grow +/// a Windows `\\?\` prefix: walkdir does not canonicalize — every yielded +/// path is `root` plus name components — and the callers pass plainly-spelled +/// roots, so `walk_filter`'s full-path ignore matching sees the same spelling +/// the patterns use. fn walk_entries<'a>( root: &str, follow_symlinks: bool, @@ -278,7 +286,6 @@ pub fn filtered_dirs<'a>( .filter(|entry| entry.file_type().is_dir()) } - #[cfg(unix)] fn parse_wc_l_stdout(bytes: &[u8]) -> Result { let s = String::from_utf8_lossy(bytes); @@ -425,15 +432,14 @@ pub fn count_tree_entries_fast( } #[cfg(all(unix, target_os = "linux"))] { - return count_find_pipe_wc(path, cancel, true) - .or_else(|e| { - if e.contains("cancelled") { - Err(e) - } else { - // Non-GNU find without -printf: plain listing. - count_find_pipe_wc(path, cancel, false) - } - }); + return count_find_pipe_wc(path, cancel, true).or_else(|e| { + if e.contains("cancelled") { + Err(e) + } else { + // Non-GNU find without -printf: plain listing. + count_find_pipe_wc(path, cancel, false) + } + }); } #[cfg(all(unix, not(target_os = "linux")))] { @@ -488,13 +494,13 @@ fn safe_truncate_string(s: &str, max_bytes: usize) -> String { if s.len() <= max_bytes { return s.to_string(); } - + // Find the last valid UTF-8 character boundary at or before max_bytes let mut end = max_bytes; while end > 0 && !s.is_char_boundary(end) { end -= 1; } - + s[..end].to_string() } @@ -779,8 +785,7 @@ pub fn content_extractable( config: &Config, registry: &Registry, ) -> bool { - crate::config::content_allowed(path, config) - && mime.is_some_and(|m| registry.supports(m)) + crate::config::content_allowed(path, config) && mime.is_some_and(|m| registry.supports(m)) } /// Read `path` and decide what its content row should say. No database access, @@ -1165,8 +1170,7 @@ pub fn store_extracted( .unchecked_transaction() .map_err(|e| format!("Failed to begin transaction: {}", e))?; for row in batch { - if let Err(e) = - store_content_outcome(&tx, row.file_id, &row.name, &row.outcome, config) + if let Err(e) = store_content_outcome(&tx, row.file_id, &row.name, &row.outcome, config) { crate::log_warn!("content indexing for {}: {}", row.name, e); continue; @@ -1221,18 +1225,33 @@ mod tests { ]) .unwrap(); - let mut names: Vec = filtered_walk(root.to_str().unwrap(), false, false, &ignore, &UnreadableDirs::default()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); + let mut names: Vec = filtered_walk( + root.to_str().unwrap(), + false, + false, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); names.sort(); assert_eq!(names, vec!["keep.txt", "keep2.txt"]); // include_hidden brings back dotfiles but ignores still apply. - let mut names: Vec = filtered_walk(root.to_str().unwrap(), false, true, &ignore, &UnreadableDirs::default()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); + let mut names: Vec = filtered_walk( + root.to_str().unwrap(), + false, + true, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); names.sort(); - assert_eq!(names, vec![".dotfile", "inside.txt", "keep.txt", "keep2.txt"]); + assert_eq!( + names, + vec![".dotfile", "inside.txt", "keep.txt", "keep2.txt"] + ); std::fs::remove_dir_all(&root).ok(); } @@ -1332,9 +1351,15 @@ mod tests { let root = base.join(".config"); touch(&root.join("app.conf")); let ignore = IgnoreSet::compile(&[]).unwrap(); - let names: Vec = filtered_walk(root.to_str().unwrap(), false, false, &ignore, &UnreadableDirs::default()) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); + let names: Vec = filtered_walk( + root.to_str().unwrap(), + false, + false, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); assert_eq!(names, vec!["app.conf"]); std::fs::remove_dir_all(&base).ok(); } @@ -1372,8 +1397,14 @@ mod tests { #[test] fn db_path_strips_windows_prefixes() { - assert_eq!(path_to_db_string(Path::new("/plain/unix/path")), "/plain/unix/path"); - assert_eq!(path_to_db_string(Path::new(r"\\?\C:\docs\a.txt")), r"C:\docs\a.txt"); + assert_eq!( + path_to_db_string(Path::new("/plain/unix/path")), + "/plain/unix/path" + ); + assert_eq!( + path_to_db_string(Path::new(r"\\?\C:\docs\a.txt")), + r"C:\docs\a.txt" + ); // A share must come back as \\server\share, not UNC\server\share — // stripping a fixed four characters produces a path that cannot be // opened, and every file beneath it would be misfiled. @@ -1448,9 +1479,8 @@ mod tests { let missing = real.join("gone").join("deeper.txt"); let key = db_key_for_missing_path(&missing); - let expected = path_to_db_string( - &real.canonicalize().unwrap().join("gone").join("deeper.txt"), - ); + let expected = + path_to_db_string(&real.canonicalize().unwrap().join("gone").join("deeper.txt")); assert_eq!(key, expected, "existing prefix resolved, missing tail kept"); // A redundant component in the *existing* part is collapsed, which is @@ -1504,20 +1534,19 @@ mod tests { let ignore = IgnoreSet::compile(&[]).unwrap(); let failures = UnreadableDirs::default(); - let names: Vec = filtered_walk( - root.to_str().unwrap(), - false, - false, - &ignore, - &failures, - ) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .collect(); + let names: Vec = + filtered_walk(root.to_str().unwrap(), false, false, &ignore, &failures) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); // Restore before asserting so a failure still cleans up. std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok(); - assert_eq!(names, vec!["a.txt"], "the unreadable subtree yields nothing"); + assert_eq!( + names, + vec!["a.txt"], + "the unreadable subtree yields nothing" + ); assert!(!failures.is_empty(), "and that failure must be recorded"); assert!( failures.covers(locked.join("hidden-from-us.txt").to_str().unwrap()), @@ -1542,7 +1571,11 @@ mod tests { // Differs within the head window. std::fs::write(&c, [b"DIFF".as_slice(), &[0u8; 64], b"AAAA"].concat()).unwrap(); - let h = |p: &Path| get_file_hash(std::fs::metadata(p).unwrap().len(), p, 8).unwrap().0; + let h = |p: &Path| { + get_file_hash(std::fs::metadata(p).unwrap().len(), p, 8) + .unwrap() + .0 + }; assert_eq!(h(&a), h(&b), "tail differences are invisible by design"); assert_ne!(h(&a), h(&c), "head differences are caught"); @@ -1699,9 +1732,15 @@ mod count_and_extract_tests { let cfg = Config::default(); assert!(needs(&cfg, "notes.txt"), "plaintext is claimed"); assert!(needs(&cfg, "song.mp3"), "audio tags are content too"); - assert!(needs(&cfg, "README"), "an extensionless text head sniffs as text/plain"); + assert!( + needs(&cfg, "README"), + "an extensionless text head sniffs as text/plain" + ); assert!(!needs(&cfg, "movie.mp4"), "no extractor claims video"); - assert!(!needs(&cfg, "blob.bin"), "binary content: no MIME, no extractor"); + assert!( + !needs(&cfg, "blob.bin"), + "binary content: no MIME, no extractor" + ); // Over `maximum_text_file_size`, so the content pass would never read // it even though plaintext claims the MIME. diff --git a/crates/quicksearch-core/src/incremental.rs b/crates/quicksearch-core/src/incremental.rs index a7e84ca..4670a30 100644 --- a/crates/quicksearch-core/src/incremental.rs +++ b/crates/quicksearch-core/src/incremental.rs @@ -21,8 +21,8 @@ use crate::config::{Config, IgnoreSet}; use crate::db::repo; use crate::extract::Registry; use crate::file_handling::{ - db_key_for_missing_path, extract_and_store, filtered_walk, ExtractCursor, UnreadableDirs, - prepare_file_record_from_path, store_inline_text, + db_key_for_missing_path, extract_and_store, filtered_walk, prepare_file_record_from_path, + store_inline_text, ExtractCursor, UnreadableDirs, }; use crate::platform::path_has_hidden_component_under; use crate::watcher::FsEvent; @@ -154,7 +154,8 @@ fn upsert_file( )?; } - tx.commit().map_err(|e| format!("commit incremental tx: {}", e)) + tx.commit() + .map_err(|e| format!("commit incremental tx: {}", e)) } fn remove_path(conn: &mut Connection, path: &Path) -> Result<(), String> { @@ -263,8 +264,14 @@ mod tests { } fn apply(&mut self, event: &FsEvent) { - apply_fs_event(&mut self.conn, event, &self.config, &self.ignore, &self.registry) - .unwrap(); + apply_fs_event( + &mut self.conn, + event, + &self.config, + &self.ignore, + &self.registry, + ) + .unwrap(); } fn write(&self, name: &str, content: &str) -> std::path::PathBuf { diff --git a/crates/quicksearch-core/src/indexing.rs b/crates/quicksearch-core/src/indexing.rs index 3683f69..8504281 100644 --- a/crates/quicksearch-core/src/indexing.rs +++ b/crates/quicksearch-core/src/indexing.rs @@ -1,28 +1,20 @@ +use rusqlite::{params, Connection, InterruptHandle, OptionalExtension}; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, mpsc}; +use std::sync::{mpsc, Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use std::collections::{HashMap, HashSet}; -use rusqlite::{params, Connection, InterruptHandle, OptionalExtension}; -use crate::extract::Registry; -use crate::file_handling::{ - cleanup_stale_index_entries, - count_tree_entries_fast, - extract_scope_prepare, - store_extracted, - fts_finalize_after_text_indexing, - process_batch_inserts, - process_batch_updates, - path_to_db_string, - ExtractCursor, - FileIndexAction, - OwnedNewFile, -}; use crate::config::Config; -use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent}; use crate::db; use crate::db::repo; +use crate::extract::Registry; +use crate::file_handling::{ + cleanup_stale_index_entries, count_tree_entries_fast, extract_scope_prepare, + fts_finalize_after_text_indexing, path_to_db_string, process_batch_inserts, + process_batch_updates, store_extracted, ExtractCursor, FileIndexAction, OwnedNewFile, +}; +use crate::walk::{thread_count_for, walk_indexable_files, ParallelWalk, TryNext, WalkEvent}; /// Where one root's pipeline is in its life cycle. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -291,10 +283,7 @@ fn wal_len(path: &str) -> u64 { /// caller should abort the operation. While the suspend flag is set and stop /// is not, this parks the thread by sleeping in short increments so a later /// `resume()` unblocks it. Cheap to call in tight loops. -pub(crate) fn should_abort( - stop: &Arc, - suspend: &Arc, -) -> bool { +pub(crate) fn should_abort(stop: &Arc, suspend: &Arc) -> bool { loop { if stop.load(Ordering::Relaxed) { return true; @@ -454,7 +443,11 @@ impl IndexingService { }; } self.command_tx - .send(IndexingCommand::Start { paths, db_path, config }) + .send(IndexingCommand::Start { + paths, + db_path, + config, + }) .map_err(|e| { // The service is gone; leave the status honest rather than // stuck on a run that will never happen. @@ -478,7 +471,8 @@ impl IndexingService { // Wait for indexing to transition to stopping state let mut attempts = 0; - while attempts < 50 { // Wait up to 5 seconds + while attempts < 50 { + // Wait up to 5 seconds match self.get_status() { IndexingStatus::Stopping => break, IndexingStatus::Idle => return Ok(()), // Already stopped @@ -520,7 +514,12 @@ impl IndexingService { /// confirmed the rebuild dialog). A missing or incompatible DB means /// there is nothing to validate — the indexer will (re)build under its /// own policy anyway. - pub fn check_config_validation(&self, db_path: &str, config: &Config, indexing_path: &str) -> Result>, String> { + pub fn check_config_validation( + &self, + db_path: &str, + config: &Config, + indexing_path: &str, + ) -> Result>, String> { match db::open_existing(db_path, false) { Ok(conn) => Self::validate_config(&conn, config, indexing_path), Err(_) => Ok(None), @@ -539,7 +538,8 @@ impl IndexingService { // Wait for indexing to actually stop let mut attempts = 0; - while attempts < 50 { // Wait up to 5 seconds + while attempts < 50 { + // Wait up to 5 seconds match self.get_status() { IndexingStatus::Idle => break, // Optimizing holds the file about to be deleted, so it is @@ -576,10 +576,14 @@ impl IndexingService { ) { let stop_flag = Arc::new(AtomicBool::new(false)); let mut indexing_handle: Option> = None; - + while let Ok(command) = command_rx.recv() { match command { - IndexingCommand::Start { paths, db_path, config } => { + IndexingCommand::Start { + paths, + db_path, + config, + } => { // `start_indexing` already claimed the status and rejected // a concurrent start, so there is nothing to re-check here. @@ -606,7 +610,15 @@ impl IndexingService { // The writer thread: every DB write and every text // extraction a run performs happens here. crate::platform::set_background_priority(); - let result = Self::run_indexing(&status_clone, &paths_owned, &db_path_owned, &stop_flag_clone, &suspend_clone, &config_owned, &db_connection_clone); + let result = Self::run_indexing( + &status_clone, + &paths_owned, + &db_path_owned, + &stop_flag_clone, + &suspend_clone, + &config_owned, + &db_connection_clone, + ); // Released before maintenance, not after: VACUUM needs // its own connection (see `db::open::open_maintenance`) @@ -641,7 +653,7 @@ impl IndexingService { } } } - + // Clean up any remaining indexing thread if let Some(handle) = indexing_handle { let _ = handle.join(); @@ -735,7 +747,7 @@ impl IndexingService { // of after a full table scan. let conn_mutex = Arc::new(Mutex::new(conn)); - + // Store the database connection for proper cleanup on stop if let Ok(mut db_opt) = db_connection.lock() { *db_opt = Some(conn_mutex.clone()); @@ -785,20 +797,22 @@ impl IndexingService { let root = root.clone(); let cancel = count_cancel.clone(); let total = count_total.clone(); - let _ = thread::Builder::new().name("qs-count".into()).spawn(move || { - crate::platform::set_background_priority(); - match count_tree_entries_fast(&root, &cancel) { - // A genuinely empty root stores 1 so "known" stays - // distinguishable from the 0 = unknown sentinel; an - // empty root's walk finishes instantly anyway. - Ok(n) => total.store(n.max(1), Ordering::Relaxed), - Err(e) => { - if !e.contains("cancelled") { - crate::log_warn!("count for {}: {}", root, e); + let _ = thread::Builder::new() + .name("qs-count".into()) + .spawn(move || { + crate::platform::set_background_priority(); + match count_tree_entries_fast(&root, &cancel) { + // A genuinely empty root stores 1 so "known" stays + // distinguishable from the 0 = unknown sentinel; an + // empty root's walk finishes instantly anyway. + Ok(n) => total.store(n.max(1), Ordering::Relaxed), + Err(e) => { + if !e.contains("cancelled") { + crate::log_warn!("count for {}: {}", root, e); + } } } - } - }); + }); } pipelines.push(RootPipeline { @@ -843,7 +857,10 @@ impl IndexingService { .collect(); if let Ok(mut g) = status.lock() { if !matches!(*g, IndexingStatus::Stopping) { - *g = IndexingStatus::Running { start_time: run_start, roots }; + *g = IndexingStatus::Running { + start_time: run_start, + roots, + }; } } }; @@ -1117,8 +1134,7 @@ impl IndexingService { } if !stale_paths.is_empty() { if let Some(first) = pipelines.first_mut() { - first.current_file = - Some("Removing stale index entries…".to_string()); + first.current_file = Some("Removing stale index entries…".to_string()); } stale_deleted = cleanup_stale_index_entries( &conn_mutex, @@ -1224,7 +1240,10 @@ impl IndexingService { /// The config keys whose change invalidates the stored index, paired /// with their current values. One list drives both [`validate_config`] /// and [`update_config`] so the two can never drift apart. - fn config_validation_entries(config: &Config, indexing_path: &str) -> Vec<(&'static str, String)> { + fn config_validation_entries( + config: &Config, + indexing_path: &str, + ) -> Vec<(&'static str, String)> { let sorted_joined = |v: &[String]| { let mut v: Vec = v.to_vec(); v.sort(); @@ -1288,11 +1307,19 @@ impl IndexingService { } } } - Ok(if changes.is_empty() { None } else { Some(changes) }) + Ok(if changes.is_empty() { + None + } else { + Some(changes) + }) } /// Stamp the index with the settings it's being built under. - fn update_config(conn: &Connection, config: &Config, indexing_path: &str) -> Result<(), String> { + fn update_config( + conn: &Connection, + config: &Config, + indexing_path: &str, + ) -> Result<(), String> { for (key, current) in Self::config_validation_entries(config, indexing_path) { conn.execute( "INSERT OR REPLACE INTO config_validation (key, value) VALUES (?1, ?2)", @@ -1333,7 +1360,6 @@ impl Default for IndexingService { } } - #[cfg(test)] mod tests { use super::*; diff --git a/crates/quicksearch-core/src/mime.rs b/crates/quicksearch-core/src/mime.rs index 3be96e4..124d38d 100644 --- a/crates/quicksearch-core/src/mime.rs +++ b/crates/quicksearch-core/src/mime.rs @@ -263,10 +263,9 @@ pub fn mime_to_type(mime: &str) -> FileType { // `text/*` (see `extract::plaintext::EXTRA_TEXT_MIMES` and the // cross-check test below). Keyed on the subtype alone, so playlists // stay AUDIO|TEXT and SVG stays IMAGE|TEXT. - "xml" | "json" | "json5" | "geo+json" | "javascript" | "mbox" | "rfc822" - | "vnd.dart" | "x-csh" | "x-httpd-php" | "x-perl" | "x-sh" | "x-sql" - | "x-subrip" | "x-tcl" | "x-tex" | "x-texinfo" | "x-troff" | "x-troff-man" - | "x-mpegurl" | "scpls" | "svg+xml" => { + "xml" | "json" | "json5" | "geo+json" | "javascript" | "mbox" | "rfc822" | "vnd.dart" + | "x-csh" | "x-httpd-php" | "x-perl" | "x-sh" | "x-sql" | "x-subrip" | "x-tcl" + | "x-tex" | "x-texinfo" | "x-troff" | "x-troff-man" | "x-mpegurl" | "scpls" | "svg+xml" => { t |= FileType::TEXT; } _ => {} @@ -292,17 +291,14 @@ mod tests { #[test] fn docx_is_document_and_office() { - let t = mime_to_type( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ); + let t = + mime_to_type("application/vnd.openxmlformats-officedocument.wordprocessingml.document"); assert!(t.contains(FileType::DOCUMENT)); } #[test] fn xlsx_is_spreadsheet_and_document() { - let t = mime_to_type( - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ); + let t = mime_to_type("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); assert!(t.contains(FileType::DOCUMENT)); assert!(t.contains(FileType::SPREADSHEET)); } @@ -341,8 +337,17 @@ mod tests { #[test] fn from_name_round_trip() { - for n in ["Audio", "Image", "Video", "Document", "Text", "Archive", - "Spreadsheet", "Presentation", "Folder"] { + for n in [ + "Audio", + "Image", + "Video", + "Document", + "Text", + "Archive", + "Spreadsheet", + "Presentation", + "Folder", + ] { assert_ne!(FileType::from_name(n), FileType::EMPTY, "{}", n); } assert_eq!(FileType::from_name("Weird"), FileType::EMPTY); @@ -435,7 +440,11 @@ mod tests { let head_bytes = crate::config::ProcessingConfig::default().hash_length; let samples: &[(&str, &[u8], &str)] = &[ - ("png", &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a], "image/png"), + ( + "png", + &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a], + "image/png", + ), ("gif", b"GIF89a", "image/gif"), ("pdf", b"%PDF-1.7", "application/pdf"), ("zip", &[0x50, 0x4b, 0x03, 0x04], "application/zip"), @@ -520,11 +529,16 @@ mod tests { ]; for (name, head) in samples { let path = PathBuf::from(name); - let mime = guess_mime_from_head(&path, head) - .unwrap_or_else(|| panic!("{} has no MIME", name)); + let mime = + guess_mime_from_head(&path, head).unwrap_or_else(|| panic!("{} has no MIME", name)); let extracted = registry .extract_complete_head(&path, &mime, head) - .unwrap_or_else(|| panic!("{} -> {} not claimed by a head-capable extractor", name, mime)) + .unwrap_or_else(|| { + panic!( + "{} -> {} not claimed by a head-capable extractor", + name, mime + ) + }) .unwrap_or_else(|e| panic!("{} -> {} failed to extract: {}", name, mime, e)); assert!( !extracted.text.is_empty(), @@ -551,7 +565,10 @@ mod tests { Some("text/plain") ); let blob = PathBuf::from("blob"); - assert_eq!(guess_mime_from_head(&blob, &[0x00, 0x01, 0x02, 0xFF]).as_deref(), None); + assert_eq!( + guess_mime_from_head(&blob, &[0x00, 0x01, 0x02, 0xFF]).as_deref(), + None + ); } /// Ambiguous extensions resolve by content in both directions: source @@ -582,8 +599,11 @@ mod tests { ); assert_eq!( - guess_mime_from_head(&PathBuf::from("go.mod"), b"module example.com/x\n\ngo 1.22\n") - .as_deref(), + guess_mime_from_head( + &PathBuf::from("go.mod"), + b"module example.com/x\n\ngo 1.22\n" + ) + .as_deref(), Some("text/plain") ); @@ -607,8 +627,7 @@ mod tests { Some("text/plain") ); assert_eq!( - guess_mime_from_head(&PathBuf::from("disk.vhd"), &[0x00, 0x01, 0x02, 0x03]) - .as_deref(), + guess_mime_from_head(&PathBuf::from("disk.vhd"), &[0x00, 0x01, 0x02, 0x03]).as_deref(), Some("application/x-virtualbox-vhd") ); } diff --git a/crates/quicksearch-core/src/platform.rs b/crates/quicksearch-core/src/platform.rs index d354752..eb67362 100644 --- a/crates/quicksearch-core/src/platform.rs +++ b/crates/quicksearch-core/src/platform.rs @@ -126,7 +126,14 @@ pub(crate) fn is_unc_string(s: &str) -> bool { /// Filesystem types whose operations are network round trips. #[cfg(target_os = "linux")] const NETWORK_FS_TYPES: [&str; 8] = [ - "cifs", "smb3", "smbfs", "nfs", "nfs4", "afs", "fuse.sshfs", "9p", + "cifs", + "smb3", + "smbfs", + "nfs", + "nfs4", + "afs", + "fuse.sshfs", + "9p", ]; /// Whether `path` lives on a network filesystem. @@ -312,7 +319,10 @@ pub fn deny_read(dir: &Path) -> std::io::Result<()> { } #[cfg(windows)] { - icacls(dir, &["/deny", &format!("{}:(OI)(CI)(RD)", current_user()?)]) + icacls( + dir, + &["/deny", &format!("{}:(OI)(CI)(RD)", current_user()?)], + ) } #[cfg(not(any(unix, windows)))] { @@ -400,7 +410,10 @@ mod tests { fn collation_matches_like_case_folding() { // LIKE folds ASCII case on every platform; the `=` half of a path // filter has to agree with it, which is what this constant is for. - assert_eq!(PATH_COLLATION, if cfg!(windows) { "NOCASE" } else { "BINARY" }); + assert_eq!( + PATH_COLLATION, + if cfg!(windows) { "NOCASE" } else { "BINARY" } + ); } #[test] @@ -427,7 +440,10 @@ mod tests { // The root itself is hidden, but it was chosen explicitly — the walk // keeps it, so the watcher must too. assert!(!path_has_hidden_component_under(&root, &roots)); - assert!(!path_has_hidden_component_under(&root.join("app.conf"), &roots)); + assert!(!path_has_hidden_component_under( + &root.join("app.conf"), + &roots + )); // A dot *below* the root still counts. assert!(path_has_hidden_component_under( diff --git a/crates/quicksearch-core/src/query/pattern.rs b/crates/quicksearch-core/src/query/pattern.rs index d79cc29..2c40aea 100644 --- a/crates/quicksearch-core/src/query/pattern.rs +++ b/crates/quicksearch-core/src/query/pattern.rs @@ -273,9 +273,7 @@ impl TermPattern { pub fn count(&self, text: &str, case_insensitive: bool) -> usize { match self { TermPattern::Empty => 0, - TermPattern::Literal(l) => { - snippet::count_occurrences(text, &l.text, !case_insensitive) - } + TermPattern::Literal(l) => snippet::count_occurrences(text, &l.text, !case_insensitive), TermPattern::Wildcard(w) => { let re = if case_insensitive { &w.search_ci diff --git a/crates/quicksearch-core/src/query/split.rs b/crates/quicksearch-core/src/query/split.rs index 1625779..460fb2e 100644 --- a/crates/quicksearch-core/src/query/split.rs +++ b/crates/quicksearch-core/src/query/split.rs @@ -113,8 +113,7 @@ pub fn split_for_cascade(input: &str) -> Result { if let Some(value) = value { // Quoted values keep `*` literal; only a bare word's // stars act as wildcards (`name:` honors this too). - let value_is_word = - matches!(tokens.get(value_idx), Some(Token::Word(_))); + let value_is_word = matches!(tokens.get(value_idx), Some(Token::Word(_))); if word.eq_ignore_ascii_case("regex") { // Not a SQL filter: compiled here, matched in // Rust against name, path and content. @@ -150,8 +149,7 @@ pub fn split_for_cascade(input: &str) -> Result { while let Some(Token::Op(next_op)) = tokens.get(i) { glued.push_str(op_str(*next_op)); i += 1; - if let Some(Token::Word(v)) | Some(Token::Quoted(v)) = tokens.get(i) - { + if let Some(Token::Word(v)) | Some(Token::Quoted(v)) = tokens.get(i) { glued.push_str(v); i += 1; } @@ -295,10 +293,14 @@ mod tests { fn a_windows_drive_path_reaches_the_filter_intact() { let q = split_for_cascade(r"path:C:\Users\me\docs").unwrap(); assert_eq!(q.term, "", "the whole input is a filter"); - assert!(matches!( - &q.filter_params[0], - Value::Text(t) if t == r"C:\Users\me\docs" - ), "{:?}", q.filter_params); + assert!( + matches!( + &q.filter_params[0], + Value::Text(t) if t == r"C:\Users\me\docs" + ), + "{:?}", + q.filter_params + ); } #[test] diff --git a/crates/quicksearch-core/src/query/translator.rs b/crates/quicksearch-core/src/query/translator.rs index a17a02a..d643b8c 100644 --- a/crates/quicksearch-core/src/query/translator.rs +++ b/crates/quicksearch-core/src/query/translator.rs @@ -14,10 +14,7 @@ pub enum TranslateError { UnknownProperty(String), BadDate(String), BadRegex(String), - UnsupportedOp { - key: String, - op: Op, - }, + UnsupportedOp { key: String, op: Op }, } impl std::fmt::Display for TranslateError { @@ -27,7 +24,11 @@ impl std::fmt::Display for TranslateError { TranslateError::BadDate(s) => write!(f, "bad date '{}'", s), TranslateError::BadRegex(s) => write!(f, "regex error: {}", s), TranslateError::UnsupportedOp { key, op } => { - write!(f, "operator {:?} is not supported for property '{}'", op, key) + write!( + f, + "operator {:?} is not supported for property '{}'", + op, key + ) } } } @@ -66,8 +67,15 @@ pub struct FilterFragment { pub fn is_filter_key(key: &str) -> bool { matches!( key.to_ascii_lowercase().as_str(), - "type" | "modified" | "mtime" | "path" | "folder" | "includefolder" | "name" - | "filename" | "mime" + "type" + | "modified" + | "mtime" + | "path" + | "folder" + | "includefolder" + | "name" + | "filename" + | "mime" ) } @@ -295,7 +303,9 @@ mod tests { assert_eq!(f.sql, "(f.type & ?) != 0"); assert_eq!( f.params, - vec![rusqlite::types::Value::Integer(FileType::AUDIO.bits() as i64)] + vec![rusqlite::types::Value::Integer( + FileType::AUDIO.bits() as i64 + )] ); } @@ -372,8 +382,17 @@ mod tests { #[test] fn is_filter_key_covers_exactly_the_supported_keys() { for key in [ - "type", "modified", "mtime", "path", "folder", "includefolder", "name", "filename", - "mime", "TYPE", "Path", + "type", + "modified", + "mtime", + "path", + "folder", + "includefolder", + "name", + "filename", + "mime", + "TYPE", + "Path", ] { assert!(is_filter_key(key), "{} should be a filter key", key); } @@ -395,11 +414,11 @@ mod tests { let base = format!("{}a{}b", root_prefix(), SEP); let rows = [ - format!("{}{}sub", base, SEP), // inside + format!("{}{}sub", base, SEP), // inside format!("{}{}sub{}deep", base, SEP, SEP), // deeper - base.clone(), // the folder itself - format!("{}a{}bc", root_prefix(), SEP), // prefix sibling: outside - format!("{}a", root_prefix()), // parent: outside + base.clone(), // the folder itself + format!("{}a{}bc", root_prefix(), SEP), // prefix sibling: outside + format!("{}a", root_prefix()), // parent: outside ]; for r in &rows { conn.execute("INSERT INTO files (parent) VALUES (?1)", [r]) @@ -426,7 +445,7 @@ mod tests { let base = format!("{}a_b", root_prefix()); for r in [ - format!("{}{}inside", base, SEP), // real child + format!("{}{}inside", base, SEP), // real child format!("{}axb{}bait", root_prefix(), SEP), // `_` must not glob to `x` format!("{}100%_done{}x", root_prefix(), SEP), ] { @@ -483,5 +502,4 @@ mod tests { // 2000-02-29 is valid (leap year) assert_eq!(civil_to_unix(2000, 2, 29), 951_782_400); } - } diff --git a/crates/quicksearch-core/src/search/cascade.rs b/crates/quicksearch-core/src/search/cascade.rs index 80f8bb2..fe267b7 100644 --- a/crates/quicksearch-core/src/search/cascade.rs +++ b/crates/quicksearch-core/src/search/cascade.rs @@ -317,12 +317,7 @@ impl<'a> Cx<'a> { /// check covers both; content is fetched (and decompressed) only for /// rows whose path missed — bounded by the pass's hit count, not its /// scan count. Pass `text` when the pass already has the content. - fn regex_accepts( - &self, - file_id: i64, - path: &str, - text: Option<&str>, - ) -> Result { + fn regex_accepts(&self, file_id: i64, path: &str, text: Option<&str>) -> Result { let Some(re) = &self.query.regex else { return Ok(true); }; @@ -449,10 +444,8 @@ impl<'a> Cx<'a> { .map(|s| escape_like(s)) .collect::>() .join("%"); - let params = self.params_with_filters(vec![rusqlite::types::Value::Text(format!( - "%{}%", - like - ))]); + let params = + self.params_with_filters(vec![rusqlite::types::Value::Text(format!("%{}%", like))]); let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; let mut rows = stmt @@ -509,7 +502,11 @@ impl<'a> Cx<'a> { // the matched span marked — the GUI renders it as [the field]. let snip = snippet::Snippet { ranges: vec![match_range], - window: if is_path_tier { path.clone() } else { name.clone() }, + window: if is_path_tier { + path.clone() + } else { + name.clone() + }, truncated_start: false, truncated_end: false, }; @@ -605,7 +602,9 @@ impl<'a> Cx<'a> { .query(rusqlite::params_from_iter(params)) .map_err(|e| e.to_string())?; - let snippet_opts = snippet::Options { approx_chars: SNIPPET_WINDOW_CHARS }; + let snippet_opts = snippet::Options { + approx_chars: SNIPPET_WINDOW_CHARS, + }; let mut buf: Vec = Vec::new(); let mut overflowed = false; let mut clock = FlushClock::new(); @@ -620,9 +619,9 @@ impl<'a> Cx<'a> { continue; } let blob: Option> = row.get(5).map_err(|e| e.to_string())?; - let text = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()).map( - |raw| String::from_utf8_lossy(&raw).into_owned(), - ); + let text = blob + .and_then(|b| zstd::decode_all(b.as_slice()).ok()) + .map(|raw| String::from_utf8_lossy(&raw).into_owned()); let (rank, stage, snip) = match &text { Some(text) => { @@ -761,9 +760,7 @@ impl<'a> Cx<'a> { None if with_paths => { let folded_path = path.to_ascii_lowercase(); match bitap.best_distance_and_first(folded_path.as_bytes()) { - Some((distance, range)) => { - (11.0 + 0.1 * distance as f64, &path, range) - } + Some((distance, range)) => (11.0 + 0.1 * distance as f64, &path, range), None => continue, } } @@ -842,7 +839,9 @@ impl<'a> Cx<'a> { .query(rusqlite::params_from_iter(params)) .map_err(|e| e.to_string())?; - let snippet_opts = snippet::Options { approx_chars: SNIPPET_WINDOW_CHARS }; + let snippet_opts = snippet::Options { + approx_chars: SNIPPET_WINDOW_CHARS, + }; let mut buf: Vec = Vec::new(); let mut overflowed = false; let mut clock = FlushClock::new(); @@ -940,7 +939,11 @@ impl<'a> Cx<'a> { }; let snip = snippet::Snippet { ranges: vec![match_range], - window: if is_path_tier { path.clone() } else { name.clone() }, + window: if is_path_tier { + path.clone() + } else { + name.clone() + }, truncated_start: false, truncated_end: false, }; @@ -990,7 +993,9 @@ impl<'a> Cx<'a> { .query(rusqlite::params_from_iter(params)) .map_err(|e| e.to_string())?; - let snippet_opts = snippet::Options { approx_chars: SNIPPET_WINDOW_CHARS }; + let snippet_opts = snippet::Options { + approx_chars: SNIPPET_WINDOW_CHARS, + }; let mut buf: Vec = Vec::new(); let mut overflowed = false; let mut clock = FlushClock::new(); diff --git a/crates/quicksearch-core/src/search/duplicates.rs b/crates/quicksearch-core/src/search/duplicates.rs index 65791f7..ed71e89 100644 --- a/crates/quicksearch-core/src/search/duplicates.rs +++ b/crates/quicksearch-core/src/search/duplicates.rs @@ -58,9 +58,7 @@ pub fn find_duplicate_groups( } let mut member_stmt = conn - .prepare( - "SELECT id, name, path, size, mtime FROM files WHERE hash = ?1 ORDER BY path", - ) + .prepare("SELECT id, name, path, size, mtime FROM files WHERE hash = ?1 ORDER BY path") .map_err(|e| e.to_string())?; for group in &mut groups { let rows = member_stmt diff --git a/crates/quicksearch-core/src/search/fuzzy.rs b/crates/quicksearch-core/src/search/fuzzy.rs index f44b4cf..9a998d6 100644 --- a/crates/quicksearch-core/src/search/fuzzy.rs +++ b/crates/quicksearch-core/src/search/fuzzy.rs @@ -71,7 +71,7 @@ impl Bitap { let done = 1u64 << (self.len - 1); let mut hit = None; let mut prev_old = r[0]; // R_old[d-1] for the d-th iteration - // d = 0: exact prefix extension only. + // d = 0: exact prefix extension only. r[0] = ((r[0] << 1) | 1) & mask; if r[0] & done != 0 { hit = Some(0); @@ -81,7 +81,7 @@ impl Bitap { r[d] = (((old << 1) | 1) & mask) // extend a ≤d-error state | prev_old // insertion in text | (prev_old << 1) // substitution - | ((r[d - 1] << 1) | 1); // deletion (pattern byte skipped) + | ((r[d - 1] << 1) | 1); // deletion (pattern byte skipped) prev_old = old; if hit.is_none() && r[d] & done != 0 { hit = Some(d); @@ -335,7 +335,9 @@ mod tests { // Deterministic LCG so the test is reproducible. let mut seed: u64 = 0x2545F4914F6CDD1D; let mut rng = move || { - seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + seed = seed + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); (seed >> 33) as usize }; let alphabet = b"abcx"; diff --git a/crates/quicksearch-core/src/search/mod.rs b/crates/quicksearch-core/src/search/mod.rs index 7d94ae5..1523751 100644 --- a/crates/quicksearch-core/src/search/mod.rs +++ b/crates/quicksearch-core/src/search/mod.rs @@ -55,10 +55,22 @@ pub struct SearchHit { #[derive(Debug, Clone)] pub enum SearchUpdate { - Started { generation: u64 }, - Hits { generation: u64, hits: Vec }, - Completed { generation: u64, total: usize, limited: bool }, - Error { generation: u64, message: String }, + Started { + generation: u64, + }, + Hits { + generation: u64, + hits: Vec, + }, + Completed { + generation: u64, + total: usize, + limited: bool, + }, + Error { + generation: u64, + message: String, + }, } impl SearchUpdate { @@ -335,8 +347,9 @@ mod tests { #[test] fn corruption_and_syntax_classification_still_work() { - assert!(classify_sql_err("database disk image is malformed") - .starts_with("DATABASE_CORRUPTED:")); + assert!( + classify_sql_err("database disk image is malformed").starts_with("DATABASE_CORRUPTED:") + ); assert!(classify_sql_err("fts5: syntax error near \"NEAR\"").starts_with("Search syntax")); assert!(classify_sql_err("no such table: files").starts_with("Search failed:")); } diff --git a/crates/quicksearch-core/src/snippet.rs b/crates/quicksearch-core/src/snippet.rs index b820d32..30009a6 100644 --- a/crates/quicksearch-core/src/snippet.rs +++ b/crates/quicksearch-core/src/snippet.rs @@ -142,7 +142,12 @@ pub fn extract_folded(text: &str, folded: &str, terms: &[&str], opts: &Options) let ranges = matches .iter() .filter(|(s, e)| *e > win_start && *s < win_end) - .map(|(s, e)| ((*s).max(win_start) - win_start, (*e).min(win_end) - win_start)) + .map(|(s, e)| { + ( + (*s).max(win_start) - win_start, + (*e).min(win_end) - win_start, + ) + }) .collect(); Snippet { diff --git a/crates/quicksearch-core/src/textenc.rs b/crates/quicksearch-core/src/textenc.rs index 882fa27..26c4133 100644 --- a/crates/quicksearch-core/src/textenc.rs +++ b/crates/quicksearch-core/src/textenc.rs @@ -110,8 +110,7 @@ pub fn decode_text(bytes: Vec, path: &Path) -> Result { TextClass::Legacy => { // ISO-2022-JP detection is safe here: the browser caveat about // it concerns script-running web content, not indexed files. - let mut det = - chardetng::EncodingDetector::new(chardetng::Iso2022JpDetection::Allow); + let mut det = chardetng::EncodingDetector::new(chardetng::Iso2022JpDetection::Allow); det.feed(&bytes, true); // Deny UTF-8: strict UTF-8 was already ruled out, so a UTF-8 // guess could only mean malformed UTF-8. @@ -119,10 +118,7 @@ pub fn decode_text(bytes: Vec, path: &Path) -> Result { let (text, _, _) = enc.decode(&bytes); Ok(text.into_owned()) } - TextClass::Binary => Err(format!( - "plaintext read {}: binary content", - path.display() - )), + TextClass::Binary => Err(format!("plaintext read {}: binary content", path.display())), } } @@ -139,7 +135,10 @@ mod tests { fn utf8_decodes_unchanged() { let body = "plain ascii and café über 日本語".as_bytes().to_vec(); assert!(looks_like_text(&body)); - assert_eq!(decode_text(body, &p()).unwrap(), "plain ascii and café über 日本語"); + assert_eq!( + decode_text(body, &p()).unwrap(), + "plain ascii and café über 日本語" + ); } #[test] @@ -203,7 +202,10 @@ mod tests { assert!(!looks_like_text(&body)); let err = decode_text(body, &p()).unwrap_err(); assert!(err.contains("binary content"), "{err}"); - assert!(err.contains("textenc-test-file"), "error must name the file: {err}"); + assert!( + err.contains("textenc-test-file"), + "error must name the file: {err}" + ); } #[test] @@ -217,7 +219,8 @@ mod tests { #[test] fn ansi_log_is_text() { // ESC-heavy colored log output stays text. - let body = b"\x1b[31mERROR\x1b[0m something failed\n\x1b[33mWARN\x1b[0m retrying\n".to_vec(); + let body = + b"\x1b[31mERROR\x1b[0m something failed\n\x1b[33mWARN\x1b[0m retrying\n".to_vec(); assert!(looks_like_text(&body)); assert!(decode_text(body, &p()).is_ok()); } diff --git a/crates/quicksearch-core/src/walk.rs b/crates/quicksearch-core/src/walk.rs index 603781e..dad9017 100644 --- a/crates/quicksearch-core/src/walk.rs +++ b/crates/quicksearch-core/src/walk.rs @@ -88,7 +88,13 @@ impl WalkedFile { /// Seen, but with nothing to write. Distinct from not being emitted at /// all: the row stays. fn skipped(path: String, digest: u128, aliased: bool) -> Self { - WalkedFile { path, action: FileIndexAction::Skip, record: None, digest, aliased } + WalkedFile { + path, + action: FileIndexAction::Skip, + record: None, + digest, + aliased, + } } } @@ -205,7 +211,10 @@ pub struct WorkerStats { impl WorkerStats { pub(crate) fn new(total: usize) -> Self { - WorkerStats { busy: Arc::new(AtomicUsize::new(0)), total } + WorkerStats { + busy: Arc::new(AtomicUsize::new(0)), + total, + } } /// Count the calling thread as busy until the returned guard drops. @@ -243,7 +252,13 @@ impl Shared { } // The prefetcher may have been parked behind PREFETCH_AHEAD. self.idle.notify_all(); - return Some((job, ActiveJob { shared: self, finished: false })); + return Some(( + job, + ActiveJob { + shared: self, + finished: false, + }, + )); } // Nothing runnable. Only "nobody anywhere holds work" proves the // walk is over — a directory sitting in the prefetch stage still @@ -452,8 +467,7 @@ fn read_directory( // `entry.metadata()` is only consulted on Windows, where it is free — // the attributes came back with the directory read. On Unix the // closure is never called, so this stays at zero extra syscalls. - if !ctx.include_hidden - && crate::platform::entry_is_hidden(&name, || entry.metadata().ok()) + if !ctx.include_hidden && crate::platform::entry_is_hidden(&name, || entry.metadata().ok()) { continue; } @@ -492,7 +506,15 @@ fn read_directory( // Resolve aliases where they are found. The target's canonical // path is what the index stores, and pushing only canonical // directories is what keeps `seen_dirs` able to break cycles. + // + // Normalized like the roots (walk_parallel), or on Windows the + // target keeps `canonicalize`'s `\\?\` prefix: every path below + // it would be spelled differently from the plainly-spelled + // roots, so full-path ignore patterns would never match under a + // followed junction and `seen_dirs` could not dedup against an + // overlapping root. if let Ok(target) = path.canonicalize() { + let target = PathBuf::from(path_to_db_string(&target)); match fs::metadata(&target) { Ok(m) if m.is_dir() => found.push(Found::Dir(target)), // The row for a resolved target belongs to the @@ -611,7 +633,13 @@ fn prepare(path: PathBuf, known: Known<'_>, ctx: &Ctx) -> WalkedFile { _ => prepare_file_record(&db_path, &meta, &ctx.config, &ctx.registry), }; - WalkedFile { path: db_path, action, record, digest, aliased } + WalkedFile { + path: db_path, + action, + record, + digest, + aliased, + } } fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { @@ -635,7 +663,10 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { Job::Files(files, rows) => (files, rows), Job::Alias(path, stored) => { slot.finish(found); - if tx.send(WalkEvent::File(prepare(path, Known::Exact(stored), ctx))).is_err() { + if tx + .send(WalkEvent::File(prepare(path, Known::Exact(stored), ctx))) + .is_err() + { shared.shutdown(); return; } @@ -658,7 +689,10 @@ fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { shared.shutdown(); return; } - if tx.send(WalkEvent::File(prepare(path, Known::InDir(&rows), ctx))).is_err() { + if tx + .send(WalkEvent::File(prepare(path, Known::InDir(&rows), ctx))) + .is_err() + { // Receiver gone: the run was stopped or failed. Not an error. shared.shutdown(); return; @@ -940,7 +974,13 @@ pub fn walk_indexable_files( }) }; - ParallelWalk { rx: Some(rx), handles, prefetch: Some(prefetch), shared, ctx } + ParallelWalk { + rx: Some(rx), + handles, + prefetch: Some(prefetch), + shared, + ctx, + } } /// Pick a worker count for these roots. @@ -1072,7 +1112,13 @@ mod tests { fn names(files: &[WalkedFile]) -> Vec { let mut n: Vec = files .iter() - .map(|f| Path::new(&f.path).file_name().unwrap().to_string_lossy().into_owned()) + .map(|f| { + Path::new(&f.path) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + }) .collect(); n.sort(); n @@ -1124,7 +1170,10 @@ mod tests { let skipped = files.iter().find(|f| f.record.is_none()).unwrap(); assert!(matches!(skipped.action, FileIndexAction::Skip)); - assert!(skipped.path.contains('\u{FFFD}'), "stored spelling is the lossy one"); + assert!( + skipped.path.contains('\u{FFFD}'), + "stored spelling is the lossy one" + ); fs::remove_dir_all(&root).ok(); } @@ -1173,7 +1222,11 @@ mod tests { .collect(); let second = walk(&root, &db_with("skip-second", &indexed)); - assert_eq!(second.len(), 2, "unchanged files are still reported as seen"); + assert_eq!( + second.len(), + 2, + "unchanged files are still reported as seen" + ); for f in &second { assert_eq!(f.action, FileIndexAction::Skip); assert!(f.record.is_none(), "an unchanged file is never hashed"); @@ -1237,7 +1290,9 @@ mod tests { }) .collect(); let recorded = !w.unreadable().is_empty(); - let covers = w.unreadable().covers(locked.join("inside.txt").to_str().unwrap()); + let covers = w + .unreadable() + .covers(locked.join("inside.txt").to_str().unwrap()); fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).ok(); @@ -1278,18 +1333,19 @@ mod tests { let root = tmp_tree("symlink-file"); touch(&root.join("real/target.txt")); fs::create_dir_all(root.join("links")).unwrap(); - std::os::unix::fs::symlink( - root.join("real/target.txt"), - root.join("links/alias.txt"), - ) - .unwrap(); + std::os::unix::fs::symlink(root.join("real/target.txt"), root.join("links/alias.txt")) + .unwrap(); let files = walk_with(&root, &empty_db("symlink-file"), true, false); let paths: HashSet<&String> = files.iter().map(|f| &f.path).collect(); assert_eq!(paths.len(), 1, "both routes report one canonical path"); let canonical = path_to_db_string(&root.join("real/target.txt").canonicalize().unwrap()); - assert_eq!(*paths.into_iter().next().unwrap(), canonical, "the target, not the alias"); + assert_eq!( + *paths.into_iter().next().unwrap(), + canonical, + "the target, not the alias" + ); // The alias itself is still reported, so its row is never mistaken for // deleted — it is reported under the *target's* path. @@ -1330,6 +1386,50 @@ mod tests { fs::remove_dir_all(&outside).ok(); } + /// Windows counterpart of the symlink tests: `canonicalize` spells a + /// junction's target `\\?\C:\…`, and the walker must strip that before + /// storing — otherwise everything beneath the junction is spelled + /// differently from the plainly-spelled roots, full-path ignore patterns + /// never match there, and the canonical-directory dedup fails. + #[test] + #[cfg(windows)] + fn a_followed_junction_stores_plain_paths() { + let root = tmp_tree("junction"); + touch(&root.join("real").join("target.txt")); + // Junctions need no privileges, unlike symlinks; still, skip cleanly + // on filesystems where mklink refuses. + let made = std::process::Command::new("cmd") + .args([ + "/C", + "mklink", + "/J", + root.join("jlink").to_str().unwrap(), + root.join("real").to_str().unwrap(), + ]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !made { + fs::remove_dir_all(&root).ok(); + return; + } + + let files = walk_with(&root, &empty_db("junction"), true, false); + for f in &files { + assert!( + !f.path.starts_with(r"\\?\"), + "stored path leaked a verbatim prefix: {}", + f.path + ); + } + // The junction resolves to the same canonical directory the walk + // reaches directly, so the dedup visits it exactly once. A leaked + // prefix would spell it twice and report the file twice. + assert_eq!(names(&files), vec!["target.txt"], "visited once"); + + fs::remove_dir_all(&root).ok(); + } + #[test] fn hidden_and_ignored_entries_are_pruned() { let root = tmp_tree("prune"); @@ -1340,11 +1440,8 @@ mod tests { touch(&root.join(".dotfile")); touch(&root.join("node_modules/dep/index.js")); - let ignore = IgnoreSet::compile(&[ - "*.tmp".to_string(), - "node_modules".to_string(), - ]) - .unwrap(); + let ignore = + IgnoreSet::compile(&["*.tmp".to_string(), "node_modules".to_string()]).unwrap(); let files: Vec = files_only(walk_indexable_files( &[root.to_string_lossy().into_owned()], false, @@ -1362,7 +1459,14 @@ mod tests { let files = walk_with(&root, &empty_db("prune-hidden"), false, true); assert_eq!( names(&files), - vec![".dotfile", "index.js", "inside.txt", "keep.txt", "keep2.txt", "skip.tmp"], + vec![ + ".dotfile", + "index.js", + "inside.txt", + "keep.txt", + "keep2.txt", + "skip.tmp" + ], "include_hidden with no ignore patterns keeps everything" ); fs::remove_dir_all(&root).ok(); @@ -1381,7 +1485,11 @@ mod tests { let kept = path_to_db_string(&root.join("kept.txt")); let db = db_with( "reconcile", - &[(gone.clone(), 1), (gone_nested.clone(), 1), (kept.clone(), 1)], + &[ + (gone.clone(), 1), + (gone_nested.clone(), 1), + (kept.clone(), 1), + ], ); let mut stale = stale_only(walk_indexable_files( @@ -1400,7 +1508,10 @@ mod tests { let mut want = vec![gone, gone_nested]; want.sort(); - assert_eq!(stale, want, "exactly the rows with no file, from both directories"); + assert_eq!( + stale, want, + "exactly the rows with no file, from both directories" + ); fs::remove_dir_all(&root).ok(); } @@ -1435,7 +1546,10 @@ mod tests { )); fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).ok(); - assert!(stale.is_empty(), "an unreadable directory is not an empty one"); + assert!( + stale.is_empty(), + "an unreadable directory is not an empty one" + ); fs::remove_dir_all(&root).ok(); } @@ -1472,7 +1586,10 @@ mod tests { 4, )); - assert!(files.len() < 500, "an already-stopped walk does not run to completion"); + assert!( + files.len() < 500, + "an already-stopped walk does not run to completion" + ); fs::remove_dir_all(&root).ok(); } @@ -1591,7 +1708,10 @@ mod tests { #[test] fn local_temp_dir_is_not_detected_as_network() { let root = tmp_tree("fstype"); - assert_eq!(thread_count_for(&[root.to_string_lossy().into_owned()]), LOCAL_THREADS); + assert_eq!( + thread_count_for(&[root.to_string_lossy().into_owned()]), + LOCAL_THREADS + ); fs::remove_dir_all(&root).ok(); } } diff --git a/crates/quicksearch-core/src/watcher.rs b/crates/quicksearch-core/src/watcher.rs index 6d7a02e..725ba64 100644 --- a/crates/quicksearch-core/src/watcher.rs +++ b/crates/quicksearch-core/src/watcher.rs @@ -63,8 +63,10 @@ use std::sync::{mpsc, Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use notify::{Config as NotifyConfig, ErrorKind as NotifyErrorKind, Event as NotifyEvent, EventKind, - RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; +use notify::{ + Config as NotifyConfig, ErrorKind as NotifyErrorKind, Event as NotifyEvent, EventKind, + RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher, +}; use crate::config::IgnoreSet; use crate::file_handling::{filtered_dirs, UnreadableDirs}; @@ -89,7 +91,10 @@ pub enum FsEvent { /// Rename where both endpoints arrived in the same notify event. For /// split rename halves (From or To only) the watcher emits Remove/Create /// instead. - Rename { from: PathBuf, to: PathBuf }, + Rename { + from: PathBuf, + to: PathBuf, + }, } /// Sink callback. Called on the watcher thread; implementors should keep @@ -110,11 +115,16 @@ pub struct WatchFilters { #[derive(Debug, Clone, PartialEq, Eq)] pub enum WatchError { /// The indexed roots hold more directories than the cap allows. - TooManyDirectories { dirs: usize, cap: usize }, + TooManyDirectories { + dirs: usize, + cap: usize, + }, /// The kernel refused a watch before our own cap was reached — /// `fs.inotify.max_user_watches` is lower than the cap, or other /// processes have consumed the shared budget. - KernelLimit { registered: usize }, + KernelLimit { + registered: usize, + }, Other(String), } @@ -640,9 +650,7 @@ fn is_event_interesting(ctx: &LoopCtx, path: &Path) -> bool { if ctx.filters.ignore.matches_path(path) { return false; } - if !ctx.filters.include_hidden - && path_has_hidden_component_under(path, &ctx.roots) - { + if !ctx.filters.include_hidden && path_has_hidden_component_under(path, &ctx.roots) { return false; } true @@ -661,8 +669,7 @@ fn handle_notify_event( // file out of an ignored directory into a watched one is a real // Create, and the reverse is a real Remove. `apply_fs_event` // re-checks each end, so passing the pair through is safe. - if !is_event_interesting(ctx, &ev.paths[0]) - && !is_event_interesting(ctx, &ev.paths[1]) + if !is_event_interesting(ctx, &ev.paths[0]) && !is_event_interesting(ctx, &ev.paths[1]) { return; } @@ -705,19 +712,16 @@ fn handle_notify_event( } } -fn enqueue( - throttle: &mut HashMap, - path: PathBuf, - op: QueuedOp, -) { - let dir = path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| path.clone()); - let entry = throttle - .entry(dir) - .or_insert_with(|| DirThrottleEntry { - record_time: Instant::now(), - queue: HashMap::new(), - immediate: true, - }); +fn enqueue(throttle: &mut HashMap, path: PathBuf, op: QueuedOp) { + let dir = path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| path.clone()); + let entry = throttle.entry(dir).or_insert_with(|| DirThrottleEntry { + record_time: Instant::now(), + queue: HashMap::new(), + immediate: true, + }); // Coalesce: Remove after Create → drop both. Modify after Modify → one Modify. match (op, entry.queue.get(&path).copied()) { (QueuedOp::Remove, Some(QueuedOp::Create)) => { @@ -892,7 +896,11 @@ mod tests { fn flush_ready_respects_max_dirs_per_tick() { let mut map: HashMap = HashMap::new(); for i in 0..10 { - enqueue(&mut map, PathBuf::from(format!("/dir{}/a", i)), QueuedOp::Create); + enqueue( + &mut map, + PathBuf::from(format!("/dir{}/a", i)), + QueuedOp::Create, + ); } let (sink, got) = sink_to_vec(); let mut config = WatcherConfig::default(); @@ -942,8 +950,13 @@ mod tests { let dir = tmp_dir("e2e"); let (sink, got) = sink_to_vec(); - let mut w = - Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink).unwrap(); + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink, + ) + .unwrap(); let f = dir.join("hello.txt"); std::fs::write(&f, "hi").unwrap(); @@ -973,21 +986,28 @@ mod tests { #[test] fn ignored_and_hidden_dirs_are_not_registered() { let dir = tmp_dir("filter"); - for sub in ["keep", "keep/nested", ".git", ".git/objects", "node_modules", - "node_modules/pkg", ".hidden"] { + for sub in [ + "keep", + "keep/nested", + ".git", + ".git/objects", + "node_modules", + "node_modules/pkg", + ".hidden", + ] { std::fs::create_dir_all(dir.join(sub)).unwrap(); } - let w = - Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0) - .unwrap(); + let w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink_to_vec().0, + ) + .unwrap(); // root + keep + keep/nested. The 4 ignored/hidden dirs cost nothing. - assert_eq!( - w.watched_dirs(), - 3, - "expected root, keep, keep/nested only" - ); + assert_eq!(w.watched_dirs(), 3, "expected root, keep, keep/nested only"); drop(w); std::fs::remove_dir_all(&dir).ok(); } @@ -1001,8 +1021,13 @@ mod tests { include_hidden: true, ..default_filters() }; - let w = Watcher::start(std::iter::once(&dir), filters, fast_config(), sink_to_vec().0) - .unwrap(); + let w = Watcher::start( + std::iter::once(&dir), + filters, + fast_config(), + sink_to_vec().0, + ) + .unwrap(); assert_eq!(w.watched_dirs(), 2, "root + .hidden"); drop(w); @@ -1022,8 +1047,13 @@ mod tests { max_watched_dirs: 2, ..fast_config() }; - let err = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) - .unwrap_err(); + let err = Watcher::start( + std::iter::once(&dir), + default_filters(), + config, + sink_to_vec().0, + ) + .unwrap_err(); assert_eq!( err, @@ -1044,8 +1074,12 @@ mod tests { std::fs::create_dir_all(&locked).unwrap(); crate::platform::deny_read(&locked).unwrap(); - let started = - Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink_to_vec().0); + let started = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink_to_vec().0, + ); crate::platform::restore_read(&locked).ok(); let w = started.expect("an unreadable directory must not fail the watcher"); @@ -1074,8 +1108,12 @@ mod tests { max_watched_dirs: 2, ..fast_config() }; - let started = - Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0); + let started = Watcher::start( + std::iter::once(&dir), + default_filters(), + config, + sink_to_vec().0, + ); crate::platform::restore_read(&locked).ok(); // Whichever order the walk visits them in, the cap is what stops us. @@ -1096,8 +1134,13 @@ mod tests { max_watched_dirs: 2, ..fast_config() }; - let w = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) - .unwrap(); + let w = Watcher::start( + std::iter::once(&dir), + default_filters(), + config, + sink_to_vec().0, + ) + .unwrap(); assert_eq!(w.watched_dirs(), 2); assert!(!w.is_degraded()); drop(w); @@ -1111,8 +1154,13 @@ mod tests { fn a_directory_created_after_start_is_watched() { let dir = tmp_dir("newdir"); let (sink, got) = sink_to_vec(); - let mut w = - Watcher::start(std::iter::once(&dir), default_filters(), fast_config(), sink).unwrap(); + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink, + ) + .unwrap(); assert_eq!(w.watched_dirs(), 1, "only the root to begin with"); let sub = dir.join("later"); @@ -1220,9 +1268,13 @@ mod tests { max_watched_dirs: 2, ..fast_config() }; - let mut w = - Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) - .unwrap(); + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + config, + sink_to_vec().0, + ) + .unwrap(); assert!(!w.is_degraded(), "one directory is under the cap of 2"); // Two more directories: the first fits, the second cannot. diff --git a/crates/quicksearch-core/tests/cascade.rs b/crates/quicksearch-core/tests/cascade.rs index 4d1a383..59ebb58 100644 --- a/crates/quicksearch-core/tests/cascade.rs +++ b/crates/quicksearch-core/tests/cascade.rs @@ -184,7 +184,10 @@ fn rank_classification_across_all_stages() { } // Full-text hits carry snippets with valid ranges. - for h in hits.iter().filter(|h| h.stage == 5 || h.stage == 6 || h.stage == 8) { + for h in hits + .iter() + .filter(|h| h.stage == 5 || h.stage == 6 || h.stage == 8) + { let snip = h.snippet.as_ref().expect("full-text hit has a snippet"); for &(a, b) in &snip.ranges { assert!(a < b && b <= snip.window.len()); @@ -206,7 +209,9 @@ fn path_substring_tiers_split_by_case() { let (hits, _) = run_collect(&conn, "Vacation", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(exact, 9), (anycase, 10)], "exact-case path matches outrank any-case ones" ); @@ -274,7 +279,9 @@ fn path_tiers_respect_the_three_char_floor() { // is off here so `ab.txt`, a 1-edit match for `abc`, stays out of it.) let (long, _) = run_collect(&conn, "abc", &SearchOptions::default()); assert_eq!( - long.iter().map(|h| (h.file_id, h.stage)).collect::>(), + long.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(dir_only, 9)], "3-char term: the directory match surfaces" ); @@ -293,7 +300,9 @@ fn term_with_separator_matches_across_the_path() { let (hits, _) = run_collect(&conn, "docs/report", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(nested, 9)], "a term spanning a separator can only match the full path" ); @@ -383,12 +392,7 @@ fn occurrence_counts_order_within_rank() { let mut s = Seeder::new(&p, true); let one = s.add("one.txt", "/d", 1, Some("zebra")); let three = s.add("three.txt", "/d", 2, Some("zebra zebra zebra")); - let thousand = s.add( - "thousand.txt", - "/d", - 3, - Some(&"zebra ".repeat(1500)), - ); + let thousand = s.add("thousand.txt", "/d", 3, Some(&"zebra ".repeat(1500))); let conn = s.done(); let (hits, _) = run_collect(&conn, "zebra", &SearchOptions::default()); @@ -551,7 +555,10 @@ fn session_ignores_hide_hits_before_the_cap() { ..SearchOptions::default() }; let (hits, outcome) = run_collect(&conn, "match", &options); - assert_eq!(hits.iter().map(|h| h.file_id).collect::>(), vec![keep]); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![keep] + ); assert_eq!(outcome.total, 1, "ignored rows never count toward totals"); drop(conn); @@ -634,9 +641,17 @@ fn wildcard_name_ranks_through_the_same_tiers() { let (hits, _) = run_collect(&conn, "report*", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), // Within rank 3 the tie breaks by name: "2024…" sorts first. - vec![(whole_cs, 1), (whole_ci, 2), (suffix, 3), (sub_cs, 3), (sub_ci, 4)], + vec![ + (whole_cs, 1), + (whole_ci, 2), + (suffix, 3), + (sub_cs, 3), + (sub_ci, 4) + ], "wildcard terms rank exactly like literal ones" ); @@ -697,7 +712,11 @@ fn wildcard_leaves_like_metacharacters_literal() { let (hits, _) = run_collect(&conn, "100*", &SearchOptions::default()); let mut ids: Vec = hits.iter().map(|h| h.file_id).collect(); ids.sort(); - assert_eq!(ids, vec![percent, underscore], "star globs, % and _ stay literal"); + assert_eq!( + ids, + vec![percent, underscore], + "star globs, % and _ stay literal" + ); drop(conn); std::fs::remove_file(&p).ok(); @@ -715,11 +734,16 @@ fn wildcard_fulltext_narrows_with_fts_and_verifies_order() { let (hits, _) = run_collect(&conn, "wond*world", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(ordered, 5)], "unordered FTS candidates must fail pattern verification" ); - let snip = hits[0].snippet.as_ref().expect("wildcard hit has a snippet"); + let snip = hits[0] + .snippet + .as_ref() + .expect("wildcard hit has a snippet"); assert_eq!(snip.ranges.len(), 1); let (a, b) = snip.ranges[0]; assert_eq!(&snip.window[a..b], "wondrous world"); @@ -740,7 +764,9 @@ fn wildcard_with_short_segments_falls_back_to_a_full_scan() { let (hits, _) = run_collect(&conn, "ab*cd", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(hit, 5)] ); @@ -764,7 +790,10 @@ fn wildcard_path_tier_and_filters() { // Structured filters gate wildcard scans like any other. let (kept, _) = run_collect(&conn, "Vac*tion path:/elsewhere", &SearchOptions::default()); - assert_eq!(kept.iter().map(|h| h.file_id).collect::>(), vec![_filtered]); + assert_eq!( + kept.iter().map(|h| h.file_id).collect::>(), + vec![_filtered] + ); drop(conn); std::fs::remove_file(&p).ok(); @@ -803,7 +832,9 @@ fn contentless_wildcard_degrades_to_unranked_stage6() { // text the row can't be pattern-verified and lands at count-unknown 6. let (hits, _) = run_collect(&conn, "wal*rus", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(doc, 6)] ); assert!(hits[0].snippet.is_none()); @@ -829,7 +860,9 @@ fn regex_only_query_hits_name_content_and_path() { let (hits, _) = run_collect(&conn, r"regex:qz\d+", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(by_name, 4), (by_content, 6), (by_path, 10)], "regex-only reuses the name/content/path tiers in cascade order" ); @@ -859,7 +892,10 @@ fn regex_is_case_insensitive_by_default_and_respects_filters() { let conn = s.done(); let (hits, _) = run_collect(&conn, r"regex:qz\d path:/keep", &SearchOptions::default()); - assert_eq!(hits.iter().map(|h| h.file_id).collect::>(), vec![keep]); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![keep] + ); // Inline opt-out flips it back to case-sensitive. let (cs, _) = run_collect(&conn, r"regex:(?-i:qz)\d", &SearchOptions::default()); @@ -882,7 +918,9 @@ fn regex_alongside_a_term_is_an_accept_predicate() { let (hits, _) = run_collect(&conn, r"budget regex:acme\d", &SearchOptions::default()); assert_eq!( - hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + hits.iter() + .map(|h| (h.file_id, h.stage)) + .collect::>(), vec![(kept, 3)], "the term drives ranking; the regex gates acceptance" ); @@ -926,7 +964,10 @@ fn service_surfaces_invalid_regex_as_an_error() { let mut message = None; while std::time::Instant::now() < deadline { match updates.recv_timeout(std::time::Duration::from_millis(200)) { - Ok(SearchUpdate::Error { generation: g, message: m }) if g == generation => { + Ok(SearchUpdate::Error { + generation: g, + message: m, + }) if g == generation => { message = Some(m); break; } @@ -1073,7 +1114,11 @@ fn a_pass_hands_hits_over_before_the_scan_reaches_the_end() { assert_eq!(outcome.total, 2, "both matches still reach the sink"); assert_eq!( - batches.first().map(|b| b.as_slice()).and_then(|b| b.first()).map(|h| h.file_id), + batches + .first() + .map(|b| b.as_slice()) + .and_then(|b| b.first()) + .map(|h| h.file_id), Some(early_worse), "the early hit should have gone out before the scan found the better one; \ batch sizes: {:?}", diff --git a/crates/quicksearch-core/tests/encrypted.rs b/crates/quicksearch-core/tests/encrypted.rs index 11a8893..42ff407 100644 --- a/crates/quicksearch-core/tests/encrypted.rs +++ b/crates/quicksearch-core/tests/encrypted.rs @@ -103,7 +103,8 @@ fn encrypted_index_lifecycle() { // Raw bytes must not leak the indexed content anywhere in the file. let raw = std::fs::read(&db_path).unwrap(); assert!( - !raw.windows(b"zebrapayload".len()).any(|w| w == b"zebrapayload"), + !raw.windows(b"zebrapayload".len()) + .any(|w| w == b"zebrapayload"), "plaintext content leaked into the encrypted file" ); @@ -187,9 +188,8 @@ fn encrypted_index_lifecycle() { drop(conn); // The right password still unlocks... - db::verify_process_key(&db_path.to_string_lossy()).expect( - "a stale schema must not make the correct password look wrong", - ); + db::verify_process_key(&db_path.to_string_lossy()) + .expect("a stale schema must not make the correct password look wrong"); // ...and the wrong one is still refused, with the same tagged error — // the relaxation must not have turned the check into a rubber stamp. db::set_process_key(Some(wrong_key.clone())); @@ -200,7 +200,10 @@ fn encrypted_index_lifecycle() { // indexer down its rebuild path. db::set_process_key(Some(key.clone())); let err = db::open_existing(&db_path.to_string_lossy(), false).unwrap_err(); - assert!(err.contains("not a compatible QuickSearch index"), "got: {err}"); + assert!( + err.contains("not a compatible QuickSearch index"), + "got: {err}" + ); // And the rebuild comes back encrypted and searchable under the same // key, so the whole path a real user walks is covered. diff --git a/crates/quicksearch-core/tests/full_index.rs b/crates/quicksearch-core/tests/full_index.rs index 7a39e49..6926c52 100644 --- a/crates/quicksearch-core/tests/full_index.rs +++ b/crates/quicksearch-core/tests/full_index.rs @@ -20,7 +20,10 @@ fn tmp_dir(tag: &str) -> PathBuf { "quicksearch-e2e-{}-{}-{}", tag, std::process::id(), - SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&p).unwrap(); p @@ -116,7 +119,10 @@ fn reindexing_an_unchanged_tree_changes_nothing() { // The whole point: a second run over an unchanged tree must not delete // and re-insert anything. A wiped-and-rebuilt row would come back with // content_state reset, throwing away extracted text for no reason. - assert_eq!(first, second, "an unchanged tree must re-index to an identical set"); + assert_eq!( + first, second, + "an unchanged tree must re-index to an identical set" + ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); @@ -140,9 +146,19 @@ fn deleted_files_are_removed_and_new_ones_added() { let names: Vec = rows(&db) .into_iter() - .map(|(p, _, _)| Path::new(&p).file_name().unwrap().to_string_lossy().into_owned()) + .map(|(p, _, _)| { + Path::new(&p) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + }) .collect(); - assert_eq!(names, vec!["added.txt", "keep.txt"], "stale cleanup still works"); + assert_eq!( + names, + vec!["added.txt", "keep.txt"], + "stale cleanup still works" + ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); @@ -439,7 +455,10 @@ fn index_roots_once(roots: &[&Path], db: &Path, config: &Config) { let service = IndexingService::new(); service .start_indexing( - roots.iter().map(|r| r.to_string_lossy().into_owned()).collect(), + roots + .iter() + .map(|r| r.to_string_lossy().into_owned()) + .collect(), db.to_string_lossy().into_owned(), config.clone(), ) @@ -475,10 +494,16 @@ fn two_roots_walk_extract_and_clean_independently() { // Imbalanced roots so the round-robin writer sees a firehose and a // trickle in the same run. for i in 0..60 { - touch(&root_a.join(format!("a{:03}.txt", i)), b"alpha corpus xylophone"); + touch( + &root_a.join(format!("a{:03}.txt", i)), + b"alpha corpus xylophone", + ); } for i in 0..5 { - touch(&root_b.join(format!("b{:03}.txt", i)), b"bravo corpus quagmire"); + touch( + &root_b.join(format!("b{:03}.txt", i)), + b"bravo corpus quagmire", + ); } index_roots_once(&[&root_a, &root_b], &db, &config); @@ -489,7 +514,11 @@ fn two_roots_walk_extract_and_clean_independently() { .unwrap(); assert_eq!(total, 65, "both roots fully walked"); let pending: i64 = conn - .query_row("SELECT COUNT(*) FROM files WHERE content_state = 0", [], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM files WHERE content_state = 0", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(pending, 0, "per-root extraction drained both roots"); // Content from EACH root is searchable. @@ -501,7 +530,11 @@ fn two_roots_walk_extract_and_clean_independently() { |r| r.get(0), ) .unwrap(); - assert!(hits > 0, "content from both roots must be indexed ({})", term); + assert!( + hits > 0, + "content from both roots must be indexed ({})", + term + ); } drop(conn); @@ -552,7 +585,13 @@ fn a_deleted_directory_takes_its_whole_subtree_out_of_the_index() { let names: Vec = rows(&db) .into_iter() - .map(|(p, _, _)| Path::new(&p).file_name().unwrap().to_string_lossy().into_owned()) + .map(|(p, _, _)| { + Path::new(&p) + .file_name() + .unwrap() + .to_string_lossy() + .into_owned() + }) .collect(); assert_eq!(names, vec!["keep.txt"], "the whole subtree is swept"); @@ -598,7 +637,9 @@ fn a_symlink_target_in_an_unwalked_directory_survives_reindexing() { let first = rows(&db); assert_eq!(first.len(), 3, "both targets indexed under their own paths"); assert!( - first.iter().any(|(p, _, _)| p.ends_with(".pruned/inner.txt")), + first + .iter() + .any(|(p, _, _)| p.ends_with(".pruned/inner.txt")), "the pruned-directory target is stored under its canonical path" ); @@ -650,7 +691,10 @@ fn a_modified_symlink_target_is_updated_not_silently_ignored() { let after = rows(&db); assert_eq!(after.len(), 1, "still exactly one row"); assert_eq!(after[0].0, before[0].0, "same path"); - assert_ne!(after[0].1, before[0].1, "mtime was refreshed, so it was re-read"); + assert_ne!( + after[0].1, before[0].1, + "mtime was refreshed, so it was re-read" + ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&outside).ok(); @@ -679,7 +723,11 @@ fn overlapping_roots_index_each_file_exactly_once() { .iter() .filter(|(p, _, _)| p.ends_with("shared.txt")) .collect(); - assert_eq!(shared.len(), 1, "the doubly-reachable file has exactly one row"); + assert_eq!( + shared.len(), + 1, + "the doubly-reachable file has exactly one row" + ); // And the overlap must not make anything look stale on a second pass. index_roots_once(&[&outer, &inner], &db, &config); @@ -774,7 +822,10 @@ fn stored_text(db: &Path, suffix: &str) -> Option { /// A tree that exercises every branch of the inline decision at once. fn seed_mixed_tree(root: &Path) { let big = "lorem ipsum dolor sit amet ".repeat(600); // ~16 KiB, past any head - touch(&root.join("small.txt"), b"a small plaintext body with xylophone in it"); + touch( + &root.join("small.txt"), + b"a small plaintext body with xylophone in it", + ); touch(&root.join("large.txt"), big.as_bytes()); touch(&root.join("empty.txt"), b""); // Binary bytes with a .txt extension: claimed by the plaintext @@ -784,8 +835,14 @@ fn seed_mixed_tree(root: &Path) { touch(&root.join("bad.txt"), &[0x68, 0x69, 0xff, 0xfe, 0x00, 0x41]); // No extension table, magic, or text sniff has an answer for NUL soup: // no MIME, no extractor. - touch(&root.join("blob.bin"), &[0x00, 0x01, 0x02, 0xfd, 0xfe, 0xff]); - touch(&root.join("nested/deep/note.md"), b"# heading\n\nquagmire body text\n"); + touch( + &root.join("blob.bin"), + &[0x00, 0x01, 0x02, 0xfd, 0xfe, 0xff], + ); + touch( + &root.join("nested/deep/note.md"), + b"# heading\n\nquagmire body text\n", + ); } #[test] @@ -844,7 +901,11 @@ fn the_head_boundary_decides_inlining_without_changing_the_result() { // Both are fully extracted; the boundary only decides *which pass* did it. let conn = rusqlite::Connection::open(&db).unwrap(); let pending: i64 = conn - .query_row("SELECT COUNT(*) FROM files WHERE content_state != 1", [], |r| r.get(0)) + .query_row( + "SELECT COUNT(*) FROM files WHERE content_state != 1", + [], + |r| r.get(0), + ) .unwrap(); assert_eq!(pending, 0, "both sides of the boundary end up extracted"); drop(conn); @@ -896,7 +957,10 @@ fn extensionless_text_files_are_indexed() { let db_dir = tmp_dir("extless-db"); let db = db_dir.join("index.sqlite"); - touch(&root.join("README"), b"QuickSearch indexes zanzibar contents.\n"); + touch( + &root.join("README"), + b"QuickSearch indexes zanzibar contents.\n", + ); touch(&root.join("Makefile"), b"all:\n\tcargo build --release\n"); touch(&root.join("go.sum"), b"example.com/x v1.0.0 h1:abcdef=\n"); touch(&root.join("blob"), &[0x00, 0x01, 0xfe, 0xff]); @@ -937,17 +1001,25 @@ fn utf16_files_are_stored_as_utf8() { let db_dir = tmp_dir("charset-db"); let db = db_dir.join("index.sqlite"); - let reg_src = "Windows Registry Editor Version 5.00\r\n\r\n[HKEY_CURRENT_USER\\Software\\Xylograph]\r\n"; + let reg_src = + "Windows Registry Editor Version 5.00\r\n\r\n[HKEY_CURRENT_USER\\Software\\Xylograph]\r\n"; let mut reg_body = vec![0xFF, 0xFE]; reg_body.extend(reg_src.encode_utf16().flat_map(|u| u.to_le_bytes())); touch(&root.join("export.reg"), ®_body); // The same encoding behind no extension at all: BOM first, sniff after. let mut extless = vec![0xFF, 0xFE]; - extless.extend("utf16 notes about quokkas".encode_utf16().flat_map(|u| u.to_le_bytes())); + extless.extend( + "utf16 notes about quokkas" + .encode_utf16() + .flat_map(|u| u.to_le_bytes()), + ); touch(&root.join("NOTES16"), &extless); - touch(&root.join("legacy.txt"), b"un caf\xe9 tr\xe8s agr\xe9able pr\xe8s du mus\xe9e"); + touch( + &root.join("legacy.txt"), + b"un caf\xe9 tr\xe8s agr\xe9able pr\xe8s du mus\xe9e", + ); index_once(&root, &db, &Config::default()); assert_eq!(stored_text(&db, "export.reg").as_deref(), Some(reg_src)); @@ -987,7 +1059,12 @@ fn rtf_files_are_extracted() { for name in ["small.rtf", "big.rtf"] { let text = stored_text(&db, name).unwrap_or_else(|| panic!("{} has no stored text", name)); - assert!(text.contains("pangolin budget"), "{}: {:?}", name, &text[..text.len().min(80)]); + assert!( + text.contains("pangolin budget"), + "{}: {:?}", + name, + &text[..text.len().min(80)] + ); assert!(!text.contains(r"\rtf"), "{} stored control words", name); } @@ -1113,7 +1190,11 @@ fn the_content_extension_filter_still_excludes_small_text_files() { } } drop(conn); - assert_eq!(stored_text(&db, "skipped.txt"), None, "no body stored for a filtered file"); + assert_eq!( + stored_text(&db, "skipped.txt"), + None, + "no body stored for a filtered file" + ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); @@ -1142,7 +1223,10 @@ fn contentless_mode_still_indexes_inlined_files_without_storing_bodies() { |r| r.get(0), ) .unwrap(); - assert_eq!(hits, 1, "an inlined file is still searchable in contentless mode"); + assert_eq!( + hits, 1, + "an inlined file is still searchable in contentless mode" + ); std::fs::remove_dir_all(&root).ok(); std::fs::remove_dir_all(&db_dir).ok(); @@ -1165,7 +1249,9 @@ fn a_heavy_root_does_not_stall_a_light_one() { // small `maximum_text_size` so the cost lands in extraction rather than in // the writer's tokenising. let heavy = tmp_dir("stall-heavy"); - let body: Vec = "sphinx of black quartz judge my vow ".repeat(40_000).into_bytes(); + let body: Vec = "sphinx of black quartz judge my vow " + .repeat(40_000) + .into_bytes(); for i in 0..200 { touch(&heavy.join(format!("d{}/big{:04}.txt", i % 8, i)), &body); } @@ -1240,7 +1326,10 @@ fn a_heavy_root_does_not_stall_a_light_one() { // The fixed design's stall does not grow with the heavy root's cost — it is // one round-robin pass plus one commit — so making that root heavier only // widens the margin. - eprintln!("longest light-root stall while heavy extracted: {:?}", worst); + eprintln!( + "longest light-root stall while heavy extracted: {:?}", + worst + ); assert!( worst < Duration::from_millis(100), "the light root stalled for {:?} while the heavy root extracted", @@ -1269,7 +1358,9 @@ fn the_wal_stays_bounded_during_a_run() { let root = tmp_dir("wal-bound"); // Wide and text-heavy: every file lands in the FTS index, which is what // actually fills the log. - let body: Vec = "sphinx of black quartz judge my vow ".repeat(200).into_bytes(); + let body: Vec = "sphinx of black quartz judge my vow " + .repeat(200) + .into_bytes(); for i in 0..4000 { touch(&root.join(format!("d{}/f{:05}.txt", i % 40, i)), &body); } @@ -1347,7 +1438,9 @@ fn the_wal_stays_bounded_during_a_run() { #[test] fn a_stopped_run_is_still_optimized() { let root = tmp_dir("stop-optimize"); - let body: Vec = "sphinx of black quartz judge my vow ".repeat(200).into_bytes(); + let body: Vec = "sphinx of black quartz judge my vow " + .repeat(200) + .into_bytes(); for i in 0..4000 { touch(&root.join(format!("d{}/f{:05}.txt", i % 40, i)), &body); } @@ -1387,11 +1480,17 @@ fn a_stopped_run_is_still_optimized() { IndexingStatus::Error(e) => panic!("indexing failed: {}", e), _ => {} } - assert!(Instant::now() < idle_by, "the stopped run never reached Idle"); + assert!( + Instant::now() < idle_by, + "the stopped run never reached Idle" + ); std::thread::sleep(Duration::from_millis(1)); } - assert!(saw_optimizing, "a stopped run must still publish Optimizing"); + assert!( + saw_optimizing, + "a stopped run must still publish Optimizing" + ); assert_eq!( std::fs::metadata(&wal).map(|m| m.len()).unwrap_or(0), 0, diff --git a/crates/quicksearch-core/tests/snippet_perf.rs b/crates/quicksearch-core/tests/snippet_perf.rs index 53d21dd..b6267e8 100644 --- a/crates/quicksearch-core/tests/snippet_perf.rs +++ b/crates/quicksearch-core/tests/snippet_perf.rs @@ -28,23 +28,85 @@ const PAGE_SIZE: usize = 50; /// Word list we draw text from. Has enough variety that trigram posting /// lists stay non-trivial (hundreds of terms, not "the" 10000 times). const WORDS: &[&str] = &[ - "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta", "theta", - "iota", "kappa", "lambda", "mu", "nu", "xi", "omicron", "pi", "rho", - "sigma", "tau", "upsilon", "phi", "chi", "psi", "omega", - "quick", "brown", "fox", "jumps", "over", "lazy", "dog", - "rust", "cargo", "sqlite", "baloo", "indexer", "tokenizer", "trigram", - "contentless", "posting", "fts5", "snippet", "highlight", - "morning", "afternoon", "evening", "midnight", "yesterday", "today", - "ocean", "forest", "mountain", "river", "valley", "bridge", "tunnel", - "tokyo", "paris", "london", "berlin", "rome", "madrid", "vienna", + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + "zeta", + "eta", + "theta", + "iota", + "kappa", + "lambda", + "mu", + "nu", + "xi", + "omicron", + "pi", + "rho", + "sigma", + "tau", + "upsilon", + "phi", + "chi", + "psi", + "omega", + "quick", + "brown", + "fox", + "jumps", + "over", + "lazy", + "dog", + "rust", + "cargo", + "sqlite", + "baloo", + "indexer", + "tokenizer", + "trigram", + "contentless", + "posting", + "fts5", + "snippet", + "highlight", + "morning", + "afternoon", + "evening", + "midnight", + "yesterday", + "today", + "ocean", + "forest", + "mountain", + "river", + "valley", + "bridge", + "tunnel", + "tokyo", + "paris", + "london", + "berlin", + "rome", + "madrid", + "vienna", ]; /// Query terms that appear in the seeded corpus, so every query returns /// real hits (not zero rows, which would skew against both paths equally /// but wouldn't exercise the snippet renderer at all). const QUERIES: &[&str] = &[ - "quick", "rust", "baloo", "morning", "paris", - "tokyo", "forest", "indexer", "contentless", "trigram", + "quick", + "rust", + "baloo", + "morning", + "paris", + "tokyo", + "forest", + "indexer", + "contentless", + "trigram", ]; fn seed_text(rng: &mut u64, target_words: usize) -> String { @@ -127,14 +189,10 @@ fn snippet_paths_perf_comparison() { let tx = conn.unchecked_transaction().unwrap(); { let mut ins_reg = tx - .prepare( - "INSERT INTO st_regular(rowid, name, text) VALUES (?1, ?2, ?3)", - ) + .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, name, text) VALUES (?1, ?2, ?3)") .unwrap(); let mut ins_blob = tx .prepare( @@ -164,17 +222,19 @@ fn snippet_paths_perf_comparison() { // Warm each table's page cache so the first run doesn't skew. for q in QUERIES.iter().take(2) { let mut s = conn - .prepare( - "SELECT rowid FROM st_regular WHERE st_regular MATCH ?1 LIMIT 50", - ) + .prepare("SELECT rowid FROM st_regular WHERE st_regular MATCH ?1 LIMIT 50") .unwrap(); - let _ = s.query_map(params![q], |r| r.get::<_, i64>(0)).unwrap().count(); + let _ = s + .query_map(params![q], |r| r.get::<_, i64>(0)) + .unwrap() + .count(); let mut s = conn - .prepare( - "SELECT rowid FROM st_contentless WHERE st_contentless MATCH ?1 LIMIT 50", - ) + .prepare("SELECT rowid FROM st_contentless WHERE st_contentless MATCH ?1 LIMIT 50") .unwrap(); - let _ = s.query_map(params![q], |r| r.get::<_, i64>(0)).unwrap().count(); + let _ = s + .query_map(params![q], |r| r.get::<_, i64>(0)) + .unwrap() + .count(); } // Path A: SQLite's built-in snippet() on a regular FTS5 table. @@ -192,7 +252,11 @@ fn snippet_paths_perf_comparison() { .unwrap(); let rows = stmt .query_map(params![q, PAGE_SIZE as i64], |r| { - Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?)) + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + )) }) .unwrap(); for r in rows { @@ -225,10 +289,7 @@ fn snippet_paths_perf_comparison() { .unwrap(); let rows = stmt .query_map(params![q, PAGE_SIZE as i64], |r| { - Ok(( - r.get::<_, i64>(0)?, - r.get::<_, Option>>(1)?, - )) + Ok((r.get::<_, i64>(0)?, r.get::<_, Option>>(1)?)) }) .unwrap(); for row in rows { @@ -253,8 +314,10 @@ fn snippet_paths_perf_comparison() { let a_per_row = dur_a.as_secs_f64() / rows_a_total as f64 * 1_000_000.0; let b_per_row = dur_b.as_secs_f64() / rows_b_total as f64 * 1_000_000.0; eprintln!(); - eprintln!("snippet perf (NUM_DOCS={NUM_DOCS}, PAGE_SIZE={PAGE_SIZE}, QUERIES={}, reps={a_reps}):", - QUERIES.len()); + eprintln!( + "snippet perf (NUM_DOCS={NUM_DOCS}, PAGE_SIZE={PAGE_SIZE}, QUERIES={}, reps={a_reps}):", + QUERIES.len() + ); eprintln!( " A (SQLite snippet(), regular FTS5): {:.2?} total, {:.2} ms/query, {:.1} µs/row ({} rows)", dur_a, a_per_query, a_per_row, rows_a_total diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs index 5e097ae..0844f2d 100644 --- a/crates/quicksearch-gui/src/app.rs +++ b/crates/quicksearch-gui/src/app.rs @@ -33,6 +33,52 @@ enum Tab { Help, } +/// A navigation the unsaved-changes guard put on hold. The intent survives +/// while the guard walks the dirty editors (on Quit there can be two); once +/// nothing relevant is dirty, [`QuickSearchApp::complete_nav`] performs it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NavIntent { + /// Leave the Manage tab for this one. + SwitchTab(Tab), + /// Close the Options window. + CloseOptions, + /// Close the application window. + Quit, +} + +/// Which editor the guard is currently asking about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnsavedSource { + Manage, + Options, +} + +/// A button (or Esc/backdrop click) in the unsaved-changes modal. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum UnsavedChoice { + Apply, + Discard, + Cancel, +} + +/// Which editor the guard must ask about for `intent`, if any. Quit asks +/// about Options before Manage — sequential prompts, one decision each; a +/// combined prompt could not Apply both drafts safely, since each is a full +/// `Config` snapshot and the second apply would revert the first. +fn guard_source( + intent: NavIntent, + manage_dirty: bool, + options_dirty: bool, +) -> Option { + match intent { + NavIntent::SwitchTab(_) => manage_dirty.then_some(UnsavedSource::Manage), + NavIntent::CloseOptions => options_dirty.then_some(UnsavedSource::Options), + NavIntent::Quit if options_dirty => Some(UnsavedSource::Options), + NavIntent::Quit if manage_dirty => Some(UnsavedSource::Manage), + NavIntent::Quit => None, + } +} + pub struct QuickSearchApp { cfg: Config, backend: Backend, @@ -63,6 +109,10 @@ pub struct QuickSearchApp { /// In-flight security flow (enable/disable/change password), driven by /// the Options window's Security block. security_prompt: Option, + /// A navigation held by the unsaved-changes guard; see [`NavIntent`]. + pending_nav: Option, + /// The guard resolved a Quit: let the next close request through. + quit_confirmed: bool, config_error: Option, } @@ -156,6 +206,8 @@ impl QuickSearchApp { stale_index_prompt, watch_cap_prompt: None, security_prompt: None, + pending_nav: None, + quit_confirmed: false, config_error, }) } @@ -184,15 +236,17 @@ impl QuickSearchApp { self.backend.start_duplicates(&cfg, ctx.clone()); } - /// Save + route an edited config to the running services. - fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) { + /// Save + route an edited config to the running services. Reports + /// whether the config was accepted — a `false` means nothing was saved + /// and the caller must keep any staged edits alive. + fn apply_new_config(&mut self, ctx: &egui::Context, mut new: Config) -> bool { pin_live_fields(&mut new, &self.cfg); if let Some((child, parent)) = nested_roots(&new.paths.indexing_paths).first() { self.config_error = Some(format!( "Not applied: indexed folder {} is nested under {}", child, parent )); - return; + return false; } // Warned-root memory only means anything for folders still indexed. // Pruning here is what makes removing and re-adding a folder warn @@ -243,6 +297,7 @@ impl QuickSearchApp { } } self.cfg = new; + true } /// Switch the indexing mode and write it to the config immediately. @@ -946,6 +1001,80 @@ impl QuickSearchApp { self.clear_prompt = false; } } + + /// Drive the unsaved-changes guard. Each frame the pending intent picks + /// the editor to ask about; Apply and Discard clean one editor and let + /// the next frame either move to the second (Quit with both dirty asks + /// about Options, then Manage) or fall through to the navigation + /// itself. Cancel — button, Esc, or a backdrop click — drops the intent + /// and stays put. + fn unsaved_prompt_ui(&mut self, ctx: &egui::Context) { + let Some(intent) = self.pending_nav else { + return; + }; + let dirty = (self.manage.is_dirty(), self.options.is_dirty(&self.cfg)); + let Some(source) = guard_source(intent, dirty.0, dirty.1) else { + return self.complete_nav(ctx, intent); + }; + match unsaved_changes_modal(ctx, source) { + None => {} + Some(UnsavedChoice::Cancel) => self.pending_nav = None, + Some(UnsavedChoice::Discard) => match source { + UnsavedSource::Manage => self.manage.discard(), + UnsavedSource::Options => self.options.close_discard(), + }, + Some(UnsavedChoice::Apply) => { + let ok = match source { + UnsavedSource::Manage => match self.manage.take_apply_config(&self.cfg) { + Some(cfg) => { + let ok = self.apply_new_config(ctx, cfg); + if ok { + self.manage.mark_applied(); + } + ok + } + None => true, + }, + UnsavedSource::Options => match self.options.draft_config() { + Some(cfg) => { + let ok = self.apply_new_config(ctx, cfg); + if ok { + self.options.close_discard(); + } + ok + } + None => true, + }, + }; + if !ok { + // Rejected (nested roots): stay put, keep the staged + // edits; the error banner explains what to fix. + self.pending_nav = None; + } + } + } + } + + /// Perform a navigation the guard has cleared. + fn complete_nav(&mut self, ctx: &egui::Context, intent: NavIntent) { + self.pending_nav = None; + match intent { + NavIntent::SwitchTab(tab) => { + let was = self.tab; + self.tab = tab; + // Same trigger the direct switch in `update` runs; a guarded + // switch lands here instead. + if tab == Tab::Duplicates && was != Tab::Duplicates { + self.start_duplicates_scan(ctx); + } + } + NavIntent::CloseOptions => self.options.close_discard(), + NavIntent::Quit => { + self.quit_confirmed = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + } } /// Overwrite the fields a config draft must never carry back. @@ -956,11 +1085,59 @@ impl QuickSearchApp { /// change. A draft taken before one of those clicks still holds the old /// value, so applying it would silently revert protection, the salt, or /// the indexing mode. -fn pin_live_fields(new: &mut Config, live: &Config) { +pub(crate) fn pin_live_fields(new: &mut Config, live: &Config) { new.security = live.security.clone(); new.indexing.auto_index = live.indexing.auto_index; } +/// Body of the unsaved-changes guard; `Some(choice)` when the user decided +/// this frame. Esc and a click on the backdrop count as Cancel. +/// +/// The one `egui::Modal` in the app, deliberately: unlike the centered +/// `egui::Window` the other prompts use, its backdrop blocks input to +/// everything behind it — this guard exists to force a decision, and a +/// click that lands on the tab strip or the Options ✕ behind the prompt +/// would re-trigger or bypass it. +/// +/// A free function (not a method) so tests can render it, and click its +/// buttons, in a headless egui context. +fn unsaved_changes_modal(ctx: &egui::Context, source: UnsavedSource) -> Option { + let mut choice = None; + let modal = egui::Modal::new(egui::Id::new("unsaved-guard")).show(ctx, |ui| { + ui.set_max_width(420.0); + ui.heading("Unsaved changes"); + ui.label(match source { + UnsavedSource::Manage => "The Manage Index tab has edits that have not been applied.", + UnsavedSource::Options => "The Options window has edits that have not been applied.", + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui + .add(crate::ui_util::bordered_button( + "Apply & Save", + crate::ui_util::BLUE, + )) + .clicked() + { + choice = Some(UnsavedChoice::Apply); + } + if ui + .button(egui::RichText::new("Discard changes").color(ui.visuals().error_fg_color)) + .clicked() + { + choice = Some(UnsavedChoice::Discard); + } + if ui.button("Cancel").clicked() { + choice = Some(UnsavedChoice::Cancel); + } + }); + }); + if choice.is_none() && modal.should_close() { + choice = Some(UnsavedChoice::Cancel); + } + choice +} + /// The stale-index window's body. Returns whether the user asked for the /// rebuild. /// @@ -1038,27 +1215,54 @@ impl eframe::App for QuickSearchApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.drain_events(); self.tick_debounce(ctx); + + // Quitting with unapplied edits gets the same guard as tab + // navigation. The close must be cancelled *this* frame — once the + // window is gone there is nothing left to ask — and re-sent from + // `complete_nav` if the user chooses to leave. + if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed { + if self.manage.is_dirty() || self.options.is_dirty(&self.cfg) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + // Quitting subsumes any narrower pending navigation. + self.pending_nav = Some(NavIntent::Quit); + } + } + self.status_bar(ctx); let previous_tab = self.tab; + // Tab clicks land on a local first, so leaving a dirty Manage tab + // can be held for the unsaved-changes guard instead of committed. + let mut requested = self.tab; egui::TopBottomPanel::top("tab-strip").show(ctx, |ui| { ui.horizontal(|ui| { - ui.selectable_value(&mut self.tab, Tab::Search, "Search"); - ui.selectable_value(&mut self.tab, Tab::Manage, "Manage Index"); - ui.selectable_value(&mut self.tab, Tab::Duplicates, "Duplicates"); - ui.selectable_value(&mut self.tab, Tab::Logs, "Logs"); - ui.selectable_value(&mut self.tab, Tab::Help, "Help"); + ui.selectable_value(&mut requested, Tab::Search, "Search"); + ui.selectable_value(&mut requested, Tab::Manage, "Manage Index"); + ui.selectable_value(&mut requested, Tab::Duplicates, "Duplicates"); + ui.selectable_value(&mut requested, Tab::Logs, "Logs"); + ui.selectable_value(&mut requested, Tab::Help, "Help"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("⚙").on_hover_text("Options").clicked() { - if self.options.open { - self.options.open = false; - } else { + if !self.options.open { self.options.open_with(&self.cfg); + } else if self.options.is_dirty(&self.cfg) { + if self.pending_nav.is_none() { + self.pending_nav = Some(NavIntent::CloseOptions); + } + } else { + self.options.close_discard(); } } }); }); }); + if requested != self.tab { + if self.tab == Tab::Manage && self.manage.is_dirty() && self.pending_nav.is_none() { + self.pending_nav = Some(NavIntent::SwitchTab(requested)); + } else { + self.tab = requested; + } + } // Entering the Duplicates tab kicks off a fresh scan. if self.tab == Tab::Duplicates && previous_tab != Tab::Duplicates { self.start_duplicates_scan(ctx); @@ -1121,7 +1325,9 @@ impl eframe::App for QuickSearchApp { ui.ctx().request_repaint_after(Duration::from_millis(100)); } if let Some(new_cfg) = actions.apply_config { - self.apply_new_config(ctx, new_cfg); + if self.apply_new_config(ctx, new_cfg) { + self.manage.mark_applied(); + } } } Tab::Duplicates => { @@ -1141,6 +1347,9 @@ impl eframe::App for QuickSearchApp { if let Some(action) = options_out.security { self.handle_security_action(action); } + if options_out.close_requested && self.pending_nav.is_none() { + self.pending_nav = Some(NavIntent::CloseOptions); + } self.rebuild_prompt_ui(ctx); self.security_prompt_ui(ctx); self.clear_prompt_ui(ctx); @@ -1150,6 +1359,8 @@ impl eframe::App for QuickSearchApp { // user is actually looking at. self.stale_index_prompt_ui(ctx); self.watch_cap_prompt_ui(ctx); + // Last: the guard must sit above everything else on screen. + self.unsaved_prompt_ui(ctx); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { @@ -1219,7 +1430,10 @@ mod tests { } } } - assert!(fired.is_some(), "no clickable Rebuild button for {source:?}"); + assert!( + fired.is_some(), + "no clickable Rebuild button for {source:?}" + ); } } @@ -1251,4 +1465,113 @@ mod tests { "the staged edit itself still applies" ); } + + /// The whole guard decision table. Quit walks Options before Manage — + /// two sequential prompts, because each draft is a full `Config` + /// snapshot and applying both in one step would let the second revert + /// the first. + #[test] + fn guard_source_orders_quit_prompts_options_first() { + use NavIntent::*; + let tab = SwitchTab(Tab::Search); + + assert_eq!(guard_source(tab, true, true), Some(UnsavedSource::Manage)); + assert_eq!(guard_source(tab, true, false), Some(UnsavedSource::Manage)); + assert_eq!( + guard_source(tab, false, true), + None, + "options guard its own close" + ); + assert_eq!(guard_source(tab, false, false), None); + + assert_eq!( + guard_source(CloseOptions, true, true), + Some(UnsavedSource::Options) + ); + assert_eq!( + guard_source(CloseOptions, false, true), + Some(UnsavedSource::Options) + ); + assert_eq!( + guard_source(CloseOptions, true, false), + None, + "manage guards tab switches" + ); + assert_eq!(guard_source(CloseOptions, false, false), None); + + assert_eq!(guard_source(Quit, true, true), Some(UnsavedSource::Options)); + assert_eq!( + guard_source(Quit, false, true), + Some(UnsavedSource::Options) + ); + assert_eq!(guard_source(Quit, true, false), Some(UnsavedSource::Manage)); + assert_eq!(guard_source(Quit, false, false), None); + } + + fn modal_frame( + ctx: &egui::Context, + source: UnsavedSource, + events: Vec, + ) -> Option { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1000.0, 700.0), + )), + events, + ..Default::default() + }; + let mut choice = None; + let _ = ctx.run(input, |ctx| choice = unsaved_changes_modal(ctx, source)); + choice + } + + /// Every way out of the guard reports the right choice: all three + /// buttons fire, Esc cancels, and an untouched frame decides nothing. + /// A backdrop click also maps to Cancel — that is `should_close`'s + /// contract — so the sweep counts button hits by their distinct values. + #[test] + fn the_unsaved_modal_reports_each_choice() { + for source in [UnsavedSource::Manage, UnsavedSource::Options] { + let ctx = egui::Context::default(); + assert_eq!( + modal_frame(&ctx, source, Vec::new()), + None, + "an untouched frame must not decide" + ); + + let mut seen = std::collections::HashSet::new(); + for y in (250..450).step_by(3) { + for x in (250..760).step_by(6) { + let pos = egui::pos2(x as f32, y as f32); + if let Some(choice) = modal_frame(&ctx, source, click_at(pos)) { + seen.insert(choice); + } + } + } + for expected in [ + UnsavedChoice::Apply, + UnsavedChoice::Discard, + UnsavedChoice::Cancel, + ] { + assert!( + seen.contains(&expected), + "{expected:?} never fired ({source:?})" + ); + } + + let esc = modal_frame( + &ctx, + source, + vec![egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ); + assert_eq!(esc, Some(UnsavedChoice::Cancel), "Esc must cancel"); + } + } } diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs index 4509321..30e3215 100644 --- a/crates/quicksearch-gui/src/manage_tab.rs +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -85,9 +85,7 @@ impl ManageTab { } // The config changed elsewhere (Options apply, a filter persisted // from the Search tab, the fuzzy toggle's direct save…). - let dirty = self.draft.as_ref() != Some(baseline) - || self.ext_filter_text != baseline.indexing.content_extensions.join("\n"); - if !dirty { + if !self.is_dirty() { // Nothing staged, nothing to lose. return self.resync(config); } @@ -98,6 +96,10 @@ impl ManageTab { let mut merged = config.clone(); merged.paths.indexing_paths = draft.paths.indexing_paths; merged.indexing = draft.indexing; + // Live state, not a user edit: the mode buttons write `auto_index` + // straight to the config, and a stale copy frozen into the draft + // here would read as permanently dirty. + merged.indexing.auto_index = config.indexing.auto_index; merged.processing = draft.processing; for pat in &config.indexing.ignore_patterns { if !baseline.indexing.ignore_patterns.contains(pat) @@ -116,6 +118,57 @@ impl ManageTab { self.baseline = Some(config.clone()); } + /// Whether the editors hold changes not yet applied. + /// + /// `security` and `indexing.auto_index` are neutralized before comparing + /// — they are live state the app pins on apply (`pin_live_fields`), not + /// user edits — and the extension text is compared parsed, so a trailing + /// newline never reads as dirty. False before the first sync. + pub fn is_dirty(&self) -> bool { + let (Some(draft), Some(baseline)) = (&self.draft, &self.baseline) else { + return false; + }; + let mut d = draft.clone(); + crate::app::pin_live_fields(&mut d, baseline); + d != *baseline + || parse_lines(&self.ext_filter_text) + != parse_lines(&baseline.indexing.content_extensions.join("\n")) + } + + /// The Apply & Save action, callable from the app's unsaved-changes + /// modal as well as the button. Syncs against `live` first — the same + /// merge the per-frame sync does — so a config applied elsewhere moments + /// ago is not reverted. Does NOT clear `baseline`: the app calls + /// [`ManageTab::mark_applied`] only after the apply succeeds, so a + /// rejected apply (nested roots) keeps the staged edits. + pub fn take_apply_config(&mut self, live: &Config) -> Option { + self.sync_editors(live); + let draft = self.draft.as_ref()?; + let mut new_config = draft.clone(); + new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text); + let roots = new_config.paths.indexing_paths.clone(); + new_config + .indexing + .root_workers + .retain(|root, _| roots.contains(root)); + Some(new_config) + } + + /// The last apply landed: resync from the applied config next frame. + pub fn mark_applied(&mut self) { + self.baseline = None; + } + + /// Drop every staged edit and editor box; the next frame resyncs from + /// the live config. + pub fn discard(&mut self) { + self.draft = None; + self.baseline = None; + self.new_root.clear(); + self.new_ignore.clear(); + self.root_error = None; + } + pub fn ui( &mut self, ui: &mut egui::Ui, @@ -373,6 +426,7 @@ impl ManageTab { self.new_ignore.clear(); } }); + crate::ui_util::pattern_hint_label(&mut cols[1], &self.new_ignore); cols[1].label( egui::RichText::new( "Changes apply on Apply & Save (may trigger index rebuild).", @@ -396,23 +450,33 @@ impl ManageTab { ); ui.add_space(8.0); - let apply = ui.add(crate::ui_util::bordered_button( - "Apply & Save", - crate::ui_util::BLUE, - )); - #[cfg(test)] - tests::record_widget("apply", &apply); - if apply.clicked() { - let mut new_config = draft.clone(); - new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text); - let roots = new_config.paths.indexing_paths.clone(); - new_config - .indexing - .root_workers - .retain(|root, _| roots.contains(root)); - actions.apply_config = Some(new_config); - self.baseline = None; - } + let dirty = self.is_dirty(); + ui.horizontal(|ui| { + let apply = ui.add(crate::ui_util::bordered_button( + "Apply & Save", + if dirty { + crate::ui_util::ORANGE + } else { + crate::ui_util::BLUE + }, + )); + #[cfg(test)] + tests::record_widget("apply", &apply); + // The label comes and goes with the dirty state; keep it + // off the ids of whatever sits after it. + crate::ui_util::stable_section(ui, |ui| { + if dirty { + ui.label( + egui::RichText::new("Unsaved changes") + .small() + .color(crate::ui_util::ORANGE), + ); + } + }); + if apply.clicked() { + actions.apply_config = self.take_apply_config(config); + } + }); }); crate::ui_util::more_below_hint(ui, &scroll); @@ -902,7 +966,13 @@ mod tests { let mut tab = ManageTab::new(); let cfg = cfg_with_root(); - frame(&ctx, &mut tab, &cfg, &running_state(&["/data"], None), vec![]); + frame( + &ctx, + &mut tab, + &cfg, + &running_state(&["/data"], None), + vec![], + ); let (baseline, _) = widget("workers"); for state in [ @@ -935,7 +1005,13 @@ mod tests { let mut tab = ManageTab::new(); let cfg = cfg_with_root(); - frame(&ctx, &mut tab, &cfg, &running_state(&["/data"], None), vec![]); + frame( + &ctx, + &mut tab, + &cfg, + &running_state(&["/data"], None), + vec![], + ); let field = widget("workers").1.center(); frame( &ctx, @@ -962,7 +1038,9 @@ mod tests { // egui fires a click on release; give it the follow-up frame. actions = frame(&ctx, &mut tab, &cfg, &busy, vec![]); } - let applied = actions.apply_config.expect("Apply & Save produced a config"); + let applied = actions + .apply_config + .expect("Apply & Save produced a config"); assert_eq!(applied.indexing.root_workers.get("/data"), Some(&8)); } @@ -1369,4 +1447,145 @@ mod tests { .count(); assert_eq!(count, 1); } + + #[test] + fn a_fresh_tab_is_not_dirty_before_or_after_its_first_sync() { + let mut tab = ManageTab::new(); + assert!(!tab.is_dirty(), "no draft yet"); + tab.sync_editors(&cfg_with_root()); + assert!(!tab.is_dirty(), "a fresh sync stages nothing"); + } + + #[test] + fn a_staged_edit_reads_dirty_and_discard_reverts_it() { + let cfg = cfg_with_root(); + let mut tab = synced_tab(&cfg); + tab.draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .push("*.jpg".into()); + tab.new_ignore = "half-typ".into(); + tab.root_error = Some("bad".into()); + assert!(tab.is_dirty()); + + tab.discard(); + assert!(!tab.is_dirty()); + assert!(tab.new_ignore.is_empty(), "scratch boxes are cleared too"); + assert!(tab.root_error.is_none()); + tab.sync_editors(&cfg); + assert_eq!(tab.draft.as_ref(), Some(&cfg), "resynced from live"); + assert!(!tab.is_dirty()); + } + + /// `parse_lines` drops blank lines and trims entries, so cosmetic + /// whitespace in the extension editor must not read as an edit. + #[test] + fn a_trailing_newline_in_the_extension_editor_is_not_dirty() { + let mut cfg = cfg_with_root(); + cfg.indexing.content_extensions = vec!["txt".into(), "md".into()]; + let mut tab = synced_tab(&cfg); + tab.ext_filter_text.push('\n'); + assert!(!tab.is_dirty(), "a blank line is not an edit"); + tab.ext_filter_text.push_str("pdf"); + assert!(tab.is_dirty(), "a real entry is"); + } + + /// The mode buttons write `auto_index` straight to the live config. A + /// stale copy frozen into a staged draft used to read as permanently + /// dirty — and applying it would have reverted the stop. + #[test] + fn a_live_mode_flip_does_not_read_as_dirty() { + let mut cfg = cfg_with_root(); + cfg.indexing.auto_index = true; + let mut tab = synced_tab(&cfg); + + // Stop clicked, nothing staged: the tab resyncs and stays clean. + let mut stopped = cfg.clone(); + stopped.indexing.auto_index = false; + tab.sync_editors(&stopped); + assert!(!tab.is_dirty()); + + // Staged edit, then Return to Automatic: dirty because of the edit + // only, and the draft adopts the live mode. + tab.draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .push("*.jpg".into()); + let mut auto_again = stopped.clone(); + auto_again.indexing.auto_index = true; + tab.sync_editors(&auto_again); + assert!(tab.is_dirty(), "the staged pattern is still pending"); + let applied = tab.take_apply_config(&auto_again).expect("a config"); + assert!( + applied.indexing.auto_index, + "applying must not revert the live mode" + ); + + // Un-staging the edit reads clean again — not permanently dirty on + // a stale mode copy. + tab.draft.as_mut().unwrap().indexing.ignore_patterns.pop(); + assert!(!tab.is_dirty()); + } + + /// `take_apply_config` must leave the editors intact: the app reports + /// back via `mark_applied` only when the apply landed, so a rejected + /// config (nested roots) keeps the user's staged edits on screen. + #[test] + fn a_rejected_apply_keeps_the_draft() { + let cfg = cfg_with_root(); + let mut tab = synced_tab(&cfg); + tab.draft + .as_mut() + .unwrap() + .paths + .indexing_paths + .push("/data/nested".into()); + + let staged = tab.take_apply_config(&cfg).expect("a config to apply"); + assert!(staged + .paths + .indexing_paths + .contains(&"/data/nested".to_string())); + assert!(tab.baseline.is_some(), "baseline survives the attempt"); + assert!(tab.is_dirty(), "the staged root is still pending"); + + tab.mark_applied(); + assert!(tab.baseline.is_none(), "a landed apply forces a resync"); + assert!(!tab.is_dirty()); + } + + /// The dirty label sits after the Apply button inside a stable section: + /// its coming and going must never rename the button, which egui hangs + /// interaction state off. + #[test] + fn the_unsaved_label_appears_without_renaming_the_apply_button() { + let ctx = egui::Context::default(); + let mut tab = ManageTab::new(); + let cfg = cfg_with_root(); + + let clean = frame_text_with(&ctx, &mut tab, &cfg, &idle_state()); + assert!( + !clean.iter().any(|t| t.contains("Unsaved changes")), + "a clean tab must not claim unsaved changes" + ); + let (clean_id, _) = widget("apply"); + + tab.draft + .as_mut() + .unwrap() + .indexing + .ignore_patterns + .push("*.jpg".into()); + let dirty = frame_text_with(&ctx, &mut tab, &cfg, &idle_state()); + assert!( + dirty.iter().any(|t| t.contains("Unsaved changes")), + "painted: {:?}", + dirty + ); + assert_eq!(widget("apply").0, clean_id, "the label renamed the button"); + } } diff --git a/crates/quicksearch-gui/src/options.rs b/crates/quicksearch-gui/src/options.rs index 8d3c804..eb2360a 100644 --- a/crates/quicksearch-gui/src/options.rs +++ b/crates/quicksearch-gui/src/options.rs @@ -31,6 +31,10 @@ pub struct OptionsOutput { pub applied: Option, /// A Security block action was clicked. pub security: Option, + /// The title-bar close was clicked while the draft holds unapplied + /// edits. The window is held open; the app raises the unsaved-changes + /// guard. + pub close_requested: bool, } pub struct OptionsWindow { @@ -58,6 +62,47 @@ impl OptionsWindow { self.keychain_probed_for = None; } + /// Whether the draft differs from the live config. The fields the app + /// pins on apply (`security`, `indexing.auto_index`) are neutralized + /// first — the Security block acts on the live config directly and must + /// not make the window read as dirty. + pub fn is_dirty(&self, current: &Config) -> bool { + let Some(draft) = &self.draft else { + return false; + }; + let mut d = draft.clone(); + crate::app::pin_live_fields(&mut d, current); + d != *current + } + + /// The draft as it stands, for the app's unsaved-changes guard. + pub fn draft_config(&self) -> Option { + self.draft.clone() + } + + /// Close and drop the draft (Discard, or a clean close). + pub fn close_discard(&mut self) { + self.open = false; + self.draft = None; + } + + /// Adopt the window's open flag for this frame. A dirty close is + /// intercepted: the window is held open and the caller is told to raise + /// the unsaved-changes guard instead. + fn intercept_close(&mut self, still_open: bool, current: &Config) -> bool { + self.open = still_open; + if self.open { + return false; + } + if self.is_dirty(current) { + self.open = true; + true + } else { + self.draft = None; + false + } + } + /// True when this index's key really is in the OS keychain: the /// preference is on *and* the keychain answers with an entry (a dead /// daemon, a locked keyring or a denied prompt all read as "no", which @@ -86,6 +131,7 @@ impl OptionsWindow { let mut out = OptionsOutput::default(); let mut open = self.open; let keychain_active = self.keychain_active(current); + let dirty = self.is_dirty(current); let draft = self.draft.as_mut().unwrap(); egui::Window::new("Options") @@ -93,91 +139,109 @@ impl OptionsWindow { .resizable(false) .default_width(420.0) .show(ctx, |ui| { - let scroll = egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| { - ui.heading(egui::RichText::new("Paths").strong()); - egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| { - ui.label("Database file"); - ui.add( - egui::TextEdit::singleline(&mut draft.paths.database_path) - .desired_width(260.0), + let scroll = egui::ScrollArea::vertical() + .max_height(480.0) + .show(ui, |ui| { + ui.heading(egui::RichText::new("Paths").strong()); + egui::Grid::new("opt-paths").num_columns(2).show(ui, |ui| { + ui.label("Database file"); + ui.add( + egui::TextEdit::singleline(&mut draft.paths.database_path) + .desired_width(260.0), + ); + ui.end_row(); + }); + ui.label( + egui::RichText::new( + "Indexed folders are managed on the Manage Index tab.", + ) + .small() + .weak(), ); - ui.end_row(); - }); - ui.label( - egui::RichText::new( - "Indexed folders are managed on the Manage Index tab.", - ) - .small() - .weak(), - ); - ui.separator(); + ui.separator(); - ui.heading(egui::RichText::new("Indexing").strong()); - config_editor_ui(ui, draft, Section::Indexing); - ui.label( - egui::RichText::new( - "Automatic and manual indexing are switched on the \ + ui.heading(egui::RichText::new("Indexing").strong()); + config_editor_ui(ui, draft, Section::Indexing); + ui.label( + egui::RichText::new( + "Automatic and manual indexing are switched on the \ Manage Index tab.", - ) - .small() - .weak(), - ); - ui.separator(); + ) + .small() + .weak(), + ); + ui.separator(); - ui.heading(egui::RichText::new("Processing").strong()); - config_editor_ui(ui, draft, Section::Processing); - ui.separator(); + ui.heading(egui::RichText::new("Processing").strong()); + config_editor_ui(ui, draft, Section::Processing); + ui.separator(); - ui.heading(egui::RichText::new("Search").strong()); - config_editor_ui(ui, draft, Section::Search); - ui.separator(); + ui.heading(egui::RichText::new("Search").strong()); + config_editor_ui(ui, draft, Section::Search); + ui.separator(); - ui.heading(egui::RichText::new("Interface").strong()); - egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { - ui.label("UI scale"); - ui.add( - egui::Slider::new(&mut draft.ui.scale, 0.5..=2.5) - .step_by(0.05) - .fixed_decimals(2), - ) - .on_hover_text( - "Zooms the whole interface: fonts, spacing, and \ + ui.heading(egui::RichText::new("Interface").strong()); + egui::Grid::new("opt-ui").num_columns(2).show(ui, |ui| { + ui.label("UI scale"); + ui.add( + egui::Slider::new(&mut draft.ui.scale, 0.5..=2.5) + .step_by(0.05) + .fixed_decimals(2), + ) + .on_hover_text( + "Zooms the whole interface: fonts, spacing, and \ widgets. Ctrl +/- and Ctrl 0 adjust it temporarily \ at runtime.", - ); - ui.end_row(); - }); - ui.separator(); + ); + ui.end_row(); + }); + ui.separator(); - // Security acts on the live config, not the draft: each - // action opens its own confirmation flow immediately. - // The KDF salt is deliberately never shown here (or - // anywhere else in the GUI). - ui.heading(egui::RichText::new("Security").strong()); - out.security = security_ui(ui, current, keychain_active); - }); + // Security acts on the live config, not the draft: each + // action opens its own confirmation flow immediately. + // The KDF salt is deliberately never shown here (or + // anywhere else in the GUI). + ui.heading(egui::RichText::new("Security").strong()); + out.security = security_ui(ui, current, keychain_active); + }); crate::ui_util::more_below_hint(ui, &scroll); ui.separator(); ui.horizontal(|ui| { - if ui.button("Apply & Save").clicked() { + let apply = ui.add(crate::ui_util::bordered_button( + "Apply & Save", + if dirty { + crate::ui_util::ORANGE + } else { + crate::ui_util::BLUE + }, + )); + if apply.clicked() { out.applied = Some(draft.clone()); } - ui.label( - egui::RichText::new( - "Changes to tokenizer, filters, hidden files, or hashing \ - prompt an index rebuild.", - ) - .small() - .weak(), - ); + // Comes and goes with the dirty state; keep it off the + // ids of the hint that follows. + crate::ui_util::stable_section(ui, |ui| { + if dirty { + ui.label( + egui::RichText::new("Unsaved changes") + .small() + .color(crate::ui_util::ORANGE), + ); + } + }); }); + ui.label( + egui::RichText::new( + "Changes to tokenizer, filters, hidden files, or hashing \ + prompt an index rebuild.", + ) + .small() + .weak(), + ); }); - self.open = open; - if !self.open { - self.draft = None; - } + out.close_requested = self.intercept_close(open, current); out } } @@ -239,95 +303,104 @@ fn security_ui( pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section) { match section { Section::Indexing => { - egui::Grid::new("cfg-indexing").num_columns(2).show(ui, |ui| { - // Automatic vs manual is deliberately absent: it is live - // state, switched (and saved) by the Stop / Return to - // Automatic buttons on the Manage Index tab. A staged copy - // of it here would fight those buttons. - ui.label("Full reindex every"); - ui.horizontal(|ui| { - ui.add( - egui::DragValue::new(&mut config.indexing.reindex_interval_minutes) - .range(5..=60 * 24 * 30), - ); - ui.label("minutes"); + egui::Grid::new("cfg-indexing") + .num_columns(2) + .show(ui, |ui| { + // Automatic vs manual is deliberately absent: it is live + // state, switched (and saved) by the Stop / Return to + // Automatic buttons on the Manage Index tab. A staged copy + // of it here would fight those buttons. + ui.label("Full reindex every"); + ui.horizontal(|ui| { + ui.add( + egui::DragValue::new(&mut config.indexing.reindex_interval_minutes) + .range(5..=60 * 24 * 30), + ); + ui.label("minutes"); + }); + ui.end_row(); + + ui.label("Follow symlinks"); + ui.checkbox(&mut config.indexing.follow_symlinks, ""); + ui.end_row(); + + ui.label("Include hidden files"); + ui.checkbox(&mut config.indexing.include_hidden, ""); + ui.end_row(); }); - ui.end_row(); - - ui.label("Follow symlinks"); - ui.checkbox(&mut config.indexing.follow_symlinks, ""); - ui.end_row(); - - ui.label("Include hidden files"); - ui.checkbox(&mut config.indexing.include_hidden, ""); - ui.end_row(); - }); } Section::Processing => { - egui::Grid::new("cfg-processing").num_columns(2).show(ui, |ui| { - ui.label("Tokenizer"); - egui::ComboBox::from_id_salt("cfg-tokenize") - .selected_text(&config.processing.tokenize) - .show_ui(ui, |ui| { - for opt in ["trigram", "unicode61", "porter"] { - ui.selectable_value( - &mut config.processing.tokenize, - opt.to_string(), - opt, - ); - } - }); - ui.end_row(); + egui::Grid::new("cfg-processing") + .num_columns(2) + .show(ui, |ui| { + ui.label("Tokenizer"); + egui::ComboBox::from_id_salt("cfg-tokenize") + .selected_text(&config.processing.tokenize) + .show_ui(ui, |ui| { + for opt in ["trigram", "unicode61", "porter"] { + ui.selectable_value( + &mut config.processing.tokenize, + opt.to_string(), + opt, + ); + } + }); + ui.end_row(); - ui.label(""); - ui.hyperlink_to( - "Tokenizer documentation", - "https://www.sqlite.org/fts5.html#tokenizers", - ); - ui.end_row(); + ui.label(""); + ui.hyperlink_to( + "Tokenizer documentation", + "https://www.sqlite.org/fts5.html#tokenizers", + ); + ui.end_row(); - ui.label("Hash sample size (bytes)"); - ui.add(egui::DragValue::new(&mut config.processing.hash_length).range(512..=1_048_576)); - ui.end_row(); + ui.label("Hash sample size (bytes)"); + ui.add( + egui::DragValue::new(&mut config.processing.hash_length) + .range(512..=1_048_576), + ); + ui.end_row(); - ui.label("Max stored text (bytes)"); - ui.add( - egui::DragValue::new(&mut config.processing.maximum_text_size) - .range(1024..=16_777_216), - ); - ui.end_row(); + ui.label("Max stored text (bytes)"); + ui.add( + egui::DragValue::new(&mut config.processing.maximum_text_size) + .range(1024..=16_777_216), + ); + ui.end_row(); - ui.label("Max text file size (bytes)"); - ui.add( - egui::DragValue::new(&mut config.processing.maximum_text_file_size) - .range(1024..=1_073_741_824), - ); - ui.end_row(); + ui.label("Max text file size (bytes)"); + ui.add( + egui::DragValue::new(&mut config.processing.maximum_text_file_size) + .range(1024..=1_073_741_824), + ); + ui.end_row(); - ui.label("Batch size"); - ui.add(egui::DragValue::new(&mut config.processing.batch_size).range(10..=100_000)); - ui.end_row(); + ui.label("Batch size"); + ui.add( + egui::DragValue::new(&mut config.processing.batch_size).range(10..=100_000), + ); + ui.end_row(); - ui.label("Max WAL size (bytes)"); - ui.add( - egui::DragValue::new(&mut config.processing.maximum_wal_size) - .range(0u64..=8_589_934_592u64), - ) - .on_hover_text( - "How large index.sqlite-wal may grow during a run before the \ + ui.label("Max WAL size (bytes)"); + ui.add( + egui::DragValue::new(&mut config.processing.maximum_wal_size) + .range(0u64..=8_589_934_592u64), + ) + .on_hover_text( + "How large index.sqlite-wal may grow during a run before the \ indexer forces a checkpoint. 0 disables forced checkpoints; \ anything below 16 MiB is raised to it.", - ); - ui.end_row(); - - ui.label("Store text for snippets"); - ui.checkbox(&mut config.processing.store_text_for_snippets, "") - .on_hover_text( - "Off: smaller index, but no previews, occurrence ranking, \ - case verification, or fuzzy full-text search", ); - ui.end_row(); - }); + ui.end_row(); + + ui.label("Store text for snippets"); + ui.checkbox(&mut config.processing.store_text_for_snippets, "") + .on_hover_text( + "Off: smaller index, but no previews, occurrence ranking, \ + case verification, or fuzzy full-text search", + ); + ui.end_row(); + }); } Section::Search => { egui::Grid::new("cfg-search").num_columns(2).show(ui, |ui| { @@ -388,3 +461,147 @@ pub fn config_editor_ui(ui: &mut egui::Ui, config: &mut Config, section: Section } } } + +#[cfg(test)] +mod tests { + use super::*; + + // All headless-safe: `keychain_active` only probes the OS keychain when + // `use_keychain` is set, and no test here sets it. + + #[test] + fn a_fresh_draft_is_not_dirty() { + let mut w = OptionsWindow::new(); + let cfg = Config::default(); + assert!(!w.is_dirty(&cfg), "no draft at all"); + w.open_with(&cfg); + assert!(!w.is_dirty(&cfg)); + } + + #[test] + fn an_edited_draft_is_dirty_until_discarded() { + let mut w = OptionsWindow::new(); + let cfg = Config::default(); + w.open_with(&cfg); + w.draft.as_mut().unwrap().search.debounce_ms += 100; + assert!(w.is_dirty(&cfg)); + w.close_discard(); + assert!(!w.open); + assert!(!w.is_dirty(&cfg), "the draft is gone"); + } + + /// The Security block and the mode buttons act on the live config while + /// the window sits open; the stale copies in the draft are not edits. + #[test] + fn live_security_and_mode_changes_are_not_dirty() { + let mut w = OptionsWindow::new(); + let mut cfg = Config::default(); + w.open_with(&cfg); + cfg.security.use_keychain = !cfg.security.use_keychain; + cfg.indexing.auto_index = !cfg.indexing.auto_index; + assert!(!w.is_dirty(&cfg)); + } + + #[test] + fn a_dirty_close_is_held_and_a_clean_one_drops_the_draft() { + let mut w = OptionsWindow::new(); + let cfg = Config::default(); + w.open_with(&cfg); + w.draft.as_mut().unwrap().search.debounce_ms += 100; + + assert!( + w.intercept_close(false, &cfg), + "dirty close raises the guard" + ); + assert!(w.open, "the window is held open until the user decides"); + assert!(w.draft.is_some(), "the draft survives"); + + assert!(!w.intercept_close(true, &cfg), "still open: nothing to do"); + + w.draft = Some(cfg.clone()); + assert!(!w.intercept_close(false, &cfg), "a clean close just closes"); + assert!(!w.open); + assert!(w.draft.is_none()); + } + + /// Where `needle` was painted this frame, as the center of its galley — + /// a click target that follows the layout instead of pinning it. + fn painted_text_center(out: &egui::FullOutput, needle: &str) -> Option { + fn walk(shape: &egui::epaint::Shape, needle: &str, found: &mut Option) { + match shape { + egui::epaint::Shape::Text(t) => { + if t.galley.text() == needle { + *found = Some(t.pos + t.galley.size() / 2.0); + } + } + egui::epaint::Shape::Vec(v) => { + for s in v { + walk(s, needle, found); + } + } + _ => {} + } + } + let mut found = None; + for clipped in &out.shapes { + walk(&clipped.shape, needle, &mut found); + } + found + } + + /// One real frame of the window in a headless context: it renders, and + /// the Apply & Save click comes back out as `applied`. + #[test] + fn the_window_renders_and_apply_reports_the_draft() { + let ctx = egui::Context::default(); + let cfg = Config::default(); + let mut w = OptionsWindow::new(); + w.open_with(&cfg); + w.draft.as_mut().unwrap().search.debounce_ms += 100; + + let run = |w: &mut OptionsWindow, events: Vec| { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1000.0, 900.0), + )), + events, + ..Default::default() + }; + let mut out = OptionsOutput::default(); + let full = ctx.run(input, |ctx| out = w.ui(ctx, &cfg)); + (out, full) + }; + + // A new egui window spends its first frames in sizing passes that + // suppress painting; run untouched frames until the settled button + // is actually on screen. + let mut target = None; + for _ in 0..5 { + let (untouched, full) = run(&mut w, vec![]); + assert!(untouched.applied.is_none()); + assert!(!untouched.close_requested); + target = painted_text_center(&full, "Apply & Save"); + if target.is_some() { + break; + } + } + let target = target.expect("the Apply & Save button was not painted"); + let clicks = [true, false] + .into_iter() + .map(|pressed| egui::Event::PointerButton { + pos: target, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }) + .collect(); + let (clicked, _) = run(&mut w, clicks); + let applied = clicked.applied.expect("the click did not report a config"); + assert_eq!( + applied.search.debounce_ms, + Config::default().search.debounce_ms + 100, + "the click reported the edited draft" + ); + } +} diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs index ccf8420..26d3e36 100644 --- a/crates/quicksearch-gui/src/search_tab.rs +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -29,6 +29,14 @@ pub struct IgnoreDialog { pub persist: bool, } +/// Glob ignoring everything under `dir`, spelled with the platform +/// separator. `Path::join` inserts a separator only where one is needed, so +/// a drive root yields `C:\*` rather than the never-matching `C:\/*` a +/// `format!("{}/*")` would produce. +fn dir_ignore_pattern(dir: &std::path::Path) -> String { + dir.join("*").to_string_lossy().into_owned() +} + /// What the tab asks the app to do after this frame. #[derive(Default)] pub struct SearchActions { @@ -51,12 +59,7 @@ pub struct SearchActions { /// the table with whatever the scan happened to reach first and never show the /// good ones. Dropping the worst-ranked instead means a rank-1 hit found late /// in a scan still displaces a rank-10 one found early. -fn admit( - set: &mut Vec, - incoming: Vec, - limit: usize, - limited: &mut bool, -) { +fn admit(set: &mut Vec, incoming: Vec, limit: usize, limited: &mut bool) { set.extend(incoming); if set.len() > limit { set.sort_by(|a, b| { @@ -644,8 +647,7 @@ impl SearchTab { name_pattern: hit.name.clone(), dir_pattern: std::path::Path::new(&hit.path) .parent() - .and_then(|p| p.to_str()) - .map(|p| format!("{}/*", p)) + .map(dir_ignore_pattern) .unwrap_or_default(), persist: false, }); @@ -713,6 +715,9 @@ impl SearchTab { } }); }); + // Inside a stable section, or the hint's appearance would + // rename the directory editor below and drop its focus. + crate::ui_util::pattern_hint_label(ui, &dialog.name_pattern); ui.separator(); // --- Directory --------------------------------------------- @@ -1211,7 +1216,10 @@ mod tests { tab.sort = (SortKey::Size, false); tab.sort_dirty = true; tab.resort(); - assert_eq!(displayed(&tab), vec!["zucchini.txt", "mango.txt", "apple.txt"]); + assert_eq!( + displayed(&tab), + vec!["zucchini.txt", "mango.txt", "apple.txt"] + ); } /// The user may re-key the sort at any time, including while results are @@ -1222,7 +1230,11 @@ mod tests { let mut tab = streaming_tab(); batch(&mut tab, vec![hit(1, "delta.txt", 1.0, 30)]); batch(&mut tab, vec![hit(2, "alpha.txt", 5.0, 10)]); - assert_eq!(displayed(&tab), vec!["delta.txt", "alpha.txt"], "rank order"); + assert_eq!( + displayed(&tab), + vec!["delta.txt", "alpha.txt"], + "rank order" + ); // Header click, mid-search. tab.sort = (SortKey::Name, true); @@ -1326,8 +1338,7 @@ mod tests { batch(&mut tab, vec![hit(2, "better.txt", 1.0, 20)]); let sel = tab.selected.expect("still selected"); assert_eq!( - tab.results[sel as usize].file_id, - 1, + tab.results[sel as usize].file_id, 1, "selection follows the file, not the slot" ); } @@ -1351,4 +1362,25 @@ mod tests { assert_eq!(tab.selected, Some(1)); assert!(egui::Popup::is_any_open(&ctx)); } + + /// `Path::join` adds a separator only where one is needed, so the + /// pattern is spelled natively and a drive root does not become the + /// never-matching `C:\/*`. + #[test] + fn dir_ignore_patterns_use_the_platform_separator() { + use std::path::Path; + #[cfg(unix)] + { + assert_eq!(dir_ignore_pattern(Path::new("/home/x")), "/home/x/*"); + assert_eq!(dir_ignore_pattern(Path::new("/")), "/*"); + } + #[cfg(windows)] + { + assert_eq!( + dir_ignore_pattern(Path::new(r"C:\Users\x")), + r"C:\Users\x\*" + ); + assert_eq!(dir_ignore_pattern(Path::new(r"C:\")), r"C:\*"); + } + } } diff --git a/crates/quicksearch-gui/src/tracker.rs b/crates/quicksearch-gui/src/tracker.rs index 25b2c33..0a97be1 100644 --- a/crates/quicksearch-gui/src/tracker.rs +++ b/crates/quicksearch-gui/src/tracker.rs @@ -21,7 +21,9 @@ pub struct SpeedTracker { impl SpeedTracker { pub fn new() -> SpeedTracker { - SpeedTracker { points: VecDeque::new() } + SpeedTracker { + points: VecDeque::new(), + } } /// Reset between phases (each phase restarts its counter). diff --git a/crates/quicksearch-gui/src/ui_util.rs b/crates/quicksearch-gui/src/ui_util.rs index c19f120..bb94884 100644 --- a/crates/quicksearch-gui/src/ui_util.rs +++ b/crates/quicksearch-gui/src/ui_util.rs @@ -47,6 +47,44 @@ pub fn ignore_pattern_valid(pattern: &str) -> bool { !trimmed.is_empty() && IgnoreSet::compile(&[pattern.to_string()]).is_ok() } +/// An informational note for a pattern that is valid but likely does not +/// mean what was typed, or `None`. Never an error: everything it fires on +/// compiles and matches exactly as described. +/// +/// The dot-leading case fires for `.git` too — "matches only items named +/// exactly `.git`" is both true and the intended behavior there, so the +/// note stays factual rather than guessing intent. +pub fn pattern_hint(pattern: &str) -> Option { + let p = pattern.trim(); + // ".jpg" is an exact-name pattern, not an extension pattern — the trap + // behind "my ignore filters don't work" reports. + if p.len() >= 2 && p.starts_with('.') && !p.contains(['*', '?', '[', '/', '\\']) { + return Some(format!( + "Matches only files or folders named exactly \"{p}\". \ + To ignore all {p} files, use \"*{p}\"." + )); + } + // "D:" can only match a component literally named "D:", which nothing + // ever is; the working spelling keeps the separator. + let b = p.as_bytes(); + if b.len() == 2 && b[0].is_ascii_alphabetic() && b[1] == b':' { + return Some(format!( + "\"{p}\" never matches anything — use \"{p}\\\" to ignore the whole drive." + )); + } + None +} + +/// Render [`pattern_hint`] as a small orange label inside a stable section, +/// so its appearance never shifts the ids of widgets below it. +pub fn pattern_hint_label(ui: &mut egui::Ui, pattern: &str) { + stable_section(ui, |ui| { + if let Some(hint) = pattern_hint(pattern) { + ui.label(egui::RichText::new(hint).small().color(ORANGE)); + } + }); +} + /// Border color for a pattern editor holding `text`, or `None` to keep the /// theme's own border. A blank box is not wrong yet, just unfilled, so it /// stays neutral; only text the user actually typed is judged. @@ -137,7 +175,53 @@ pub fn more_below_hint(ui: &egui::Ui, out: &egui::scroll_area::ScrollAreaOutp #[cfg(test)] mod tests { - use super::{ignore_pattern_valid, pattern_border, INVALID_RED, VALID_GREEN}; + use super::{ignore_pattern_valid, pattern_border, pattern_hint, INVALID_RED, VALID_GREEN}; + + /// The trap behind "my ignore filters don't work" reports: ".jpg" is an + /// exact-name pattern, and the hint must say so and offer "*.jpg". + #[test] + fn extension_like_patterns_get_a_hint() { + let hint = pattern_hint(".jpg").expect("hint for .jpg"); + assert!(hint.contains("*.jpg"), "{}", hint); + assert!(hint.contains("exactly"), "{}", hint); + + // Fires for genuine exact-name patterns too — the statement it + // makes is just as true for .git, so no allowlist. + assert!(pattern_hint(".git").is_some()); + assert!(pattern_hint(" .venv ").is_some(), "trimmed first"); + + let targz = pattern_hint(".tar.gz").expect("hint for .tar.gz"); + assert!(targz.contains("*.tar.gz"), "{}", targz); + } + + #[test] + fn working_patterns_get_no_hint() { + for p in [ + "*.jpg", // the fixed spelling itself + "node_modules", // plain name + ".hidden*", // wildcard: the author knows about globs + ".[jJ]pg", // character class counts as a wildcard + ".git/", // separator: a path pattern + r".git\", // …either flavor + ".", // too short to be an extension + "", + " ", + "D:/", // working drive-root spelling + r"D:\", // …either flavor + "cache-??", + ] { + assert_eq!(pattern_hint(p), None, "hinted on {:?}", p); + } + } + + #[test] + fn bare_drive_letters_get_a_hint() { + let hint = pattern_hint("D:").expect("hint for D:"); + assert!(hint.contains("D:\\"), "{}", hint); + assert!(pattern_hint("d:").is_some(), "case does not matter"); + assert_eq!(pattern_hint("DD:"), None, "not a drive letter"); + assert_eq!(pattern_hint("4:"), None, "not a drive letter"); + } #[test] fn blank_patterns_are_invalid() { diff --git a/crates/quicksearch-gui/src/unlock.rs b/crates/quicksearch-gui/src/unlock.rs index 0001426..babde8e 100644 --- a/crates/quicksearch-gui/src/unlock.rs +++ b/crates/quicksearch-gui/src/unlock.rs @@ -57,7 +57,11 @@ impl Gate { .map(|app| Gate::Running(Box::new(app))) } - pub fn locked(cfg: Config, config_error: Option, initial_query: Option) -> Gate { + pub fn locked( + cfg: Config, + config_error: Option, + initial_query: Option, + ) -> Gate { Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query)) } } @@ -148,7 +152,11 @@ pub struct UnlockScreen { } impl UnlockScreen { - fn new(cfg: Config, config_error: Option, initial_query: Option) -> UnlockScreen { + fn new( + cfg: Config, + config_error: Option, + initial_query: Option, + ) -> UnlockScreen { let mode = match cfg.security.salt_bytes() { Err(e) => Mode::BrokenSalt(e), Ok(_) => { @@ -248,8 +256,8 @@ impl UnlockScreen { _ => "Create index", }; let clicked = ui.button(label).clicked(); - let entered = field.lost_focus() - && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let entered = + field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); submitted = clicked || entered; if self.focus_password && !busy { field.request_focus(); @@ -331,9 +339,7 @@ impl UnlockScreen { drop(password); db::set_process_key(Some(key.clone())); let result = match &verify_against { - Some(db_path) => { - db::verify_process_key(&db_path.to_string_lossy()).map(|()| key) - } + Some(db_path) => db::verify_process_key(&db_path.to_string_lossy()).map(|()| key), None => Ok(key), }; let _ = tx.send(result); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..df355c4 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,12 @@ +# Pinning the toolchain keeps CI from silently tracking whatever `stable` became +# overnight, and rustup honours this file automatically, so build.sh, build.bat +# and a plain `cargo build` all pick it up with no extra arguments. +# +# The floor is set by eframe/egui 0.32, which declare rust-version = "1.85". +# +# Listing the Windows target here is what lets the cross-compile CI job skip +# `rustup target add`: rustup installs everything named below on first use. +[toolchain] +channel = "1.94.1" +components = ["rustfmt"] +targets = ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu"]