diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cf34f33 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# cmd.exe is sensitive to line endings — a run.bat checked out with LF can +# misparse labels and multi-line constructs — while sh requires LF regardless +# of the platform it is checked out on. +*.bat text eol=crlf +*.cmd text eol=crlf +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 3452226..202c536 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target +/dist *.db -config.toml \ No newline at end of file +config.toml diff --git a/.vscode/launch.json b/.vscode/launch.json index afdc3e3..8d33f03 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,7 +12,7 @@ "args": [ "build", "--bin=quicksearch", - "--package=quicksearch" + "--package=quicksearch-gui" ], "filter": { "name": "quicksearch", @@ -31,7 +31,7 @@ "test", "--no-run", "--bin=quicksearch", - "--package=quicksearch" + "--package=quicksearch-gui" ], "filter": { "name": "quicksearch", diff --git a/Cargo.lock b/Cargo.lock index fe2be86..7dc3755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,19 +3,36 @@ version = 4 [[package]] -name = "addr2line" -version = "0.22.0" +name = "ab_glyph" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" dependencies = [ - "gimli", + "ab_glyph_rasterizer", + "owned_ttf_parser", ] [[package]] -name = "adler" -version = "1.0.2" +name = "ab_glyph_rasterizer" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e25ae84c0260bdf5df07796d7cc4882460de26a2b406ec0e6c42461a723b271b" +dependencies = [ + "enumn", + "serde", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "adobe-cmap-parser" @@ -34,7 +51,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.12", ] [[package]] @@ -44,7 +61,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", + "getrandom 0.2.15", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -59,10 +78,29 @@ dependencies = [ ] [[package]] -name = "allocator-api2" -version = "0.2.18" +name = "android-activity" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni 0.22.4", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.19", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" @@ -74,20 +112,56 @@ dependencies = [ ] [[package]] -name = "ashpd" -version = "0.8.1" +name = "arboard" +version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd884d7c72877a94102c3715f3b1cd09ff4fac28221add3e57cfbe25c236d093" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" dependencies = [ "async-fs", "async-net", "enumflags2", "futures-channel", "futures-util", - "rand 0.8.5", + "rand 0.9.5", + "raw-window-handle", "serde", "serde_repr", "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", "zbus", ] @@ -152,7 +226,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 0.38.34", "slab", "tracing", "windows-sys 0.52.0", @@ -195,7 +269,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 0.38.34", "tracing", "windows-sys 0.52.0", ] @@ -223,7 +297,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 0.38.34", "signal-hook-registry", "slab", "windows-sys 0.52.0", @@ -246,29 +320,6 @@ dependencies = [ "syn 2.0.66", ] -[[package]] -name = "atk" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4af014b17dd80e8af9fa689b2d4a211ddba6eb583c1622f35d0cb543f6b17e4" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251e0b7d90e33e0ba930891a505a9a35ece37b2dd37a14f3ffc306c13b980009" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -281,26 +332,11 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" -[[package]] -name = "backtrace" -version = "0.3.73" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" -dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", -] - [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" @@ -308,6 +344,21 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -316,19 +367,13 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.5.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ - "serde", + "serde_core", ] -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - [[package]] name = "block-buffer" version = "0.10.4" @@ -338,6 +383,33 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + [[package]] name = "blocking" version = "1.6.1" @@ -351,18 +423,54 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.6.0" @@ -390,60 +498,63 @@ dependencies = [ ] [[package]] -name = "cairo-rs" -version = "0.18.5" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.5.0", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror", + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.34", + "slab", + "thiserror 1.0.61", ] [[package]] -name = "cairo-sys-rs" -version = "0.18.2" +name = "calloop" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "glib-sys", - "libc", - "system-deps", + "bitflags 2.13.1", + "polling", + "rustix 1.1.4", + "slab", + "tracing", ] [[package]] -name = "camino" -version = "1.1.7" +name = "calloop-wayland-source" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0ec6b951b160caa93cc0c7b209e5a3bff7aae9062213451ac99493cd844c239" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "serde", + "calloop 0.13.0", + "rustix 0.38.34", + "wayland-backend", + "wayland-client", ] [[package]] -name = "cargo-platform" -version = "0.1.8" +name = "calloop-wayland-source" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24b1f0365a6c6bb4020cd05806fd0d33c44d38046b8bd7f0e40814b9763cabfc" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "serde", + "calloop 0.14.4", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", ] [[package]] -name = "cargo_metadata" -version = "0.18.1" +name = "cbc" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror", + "cipher", ] [[package]] @@ -475,14 +586,10 @@ dependencies = [ ] [[package]] -name = "cfg-expr" -version = "0.15.8" +name = "cff-parser" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] +checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087" [[package]] name = "cfg-if" @@ -490,18 +597,32 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -513,33 +634,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "ciborium" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" -dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", -] - -[[package]] -name = "ciborium-io" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" - -[[package]] -name = "ciborium-ll" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" -dependencies = [ - "ciborium-io", - "half", -] - [[package]] name = "cipher" version = "0.4.4" @@ -551,33 +645,23 @@ dependencies = [ ] [[package]] -name = "cocoa" -version = "0.25.0" +name = "clipboard-win" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation", - "core-foundation", - "core-graphics", - "foreign-types", - "libc", - "objc", + "error-code", ] [[package]] -name = "cocoa-foundation" -version = "0.1.2" +name = "codespan-reporting" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation", - "core-graphics-types", - "libc", - "objc", + "serde", + "termcolor", + "unicode-width", ] [[package]] @@ -599,53 +683,12 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const_format" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" -dependencies = [ - "const_format_proc_macros", -] - -[[package]] -name = "const_format_proc_macros" -version = "0.2.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - [[package]] name = "constant_time_eq" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" -[[package]] -name = "constcat" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd7e35aee659887cbfb97aaf227ac12cad1a9d7c71e55ff3376839ed4e282d08" - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "convert_case" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -656,6 +699,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.6" @@ -669,7 +722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -682,7 +735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -695,6 +748,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.4.2" @@ -754,33 +816,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "cssparser" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa 0.4.8", - "matches", - "phf 0.8.0", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.66", -] - [[package]] name = "ctrlc" version = "3.5.0" @@ -793,51 +828,10 @@ dependencies = [ ] [[package]] -name = "darling" -version = "0.20.9" +name = "cursor-icon" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b2eb4d90d12bdda5ed17de686c2acb4c57914f8f921b8da7e112b5a36f3fe1" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622687fe0bac72a04e5599029151f5796111b90f1baaa9b544d807a5e31cd120" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "darling_macro" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "733cabb43482b1a1b53eee8583c2b9e8684d592215ea83efd305dd31bc2f0178" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "dashmap" -version = "5.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" -dependencies = [ - "cfg-if", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "data-encoding" @@ -854,30 +848,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "derive_more" -version = "0.99.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 1.0.109", -] - [[package]] name = "digest" version = "0.10.7" @@ -889,287 +859,24 @@ dependencies = [ "subtle", ] -[[package]] -name = "dioxus" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e50735a28b303b0d67e1b5637fb57e4711bf2776266290cbc987c0adfdabb55" -dependencies = [ - "dioxus-config-macro", - "dioxus-core", - "dioxus-core-macro", - "dioxus-desktop", - "dioxus-fullstack", - "dioxus-hooks", - "dioxus-hot-reload", - "dioxus-html", - "dioxus-signals", -] - -[[package]] -name = "dioxus-cli-config" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d4661064bad2f0b12929faf6c9cea4d94e60217ba6b11ff4146b505a57124b" -dependencies = [ - "once_cell", - "serde", - "serde_json", - "tracing", -] - -[[package]] -name = "dioxus-config-macro" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe244197b320dec9e9f38742985fe98c058136ada770df73e9429878ed92863" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "dioxus-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "088daa3f45aaa729e9eef32dc0a9393dd709ee906b092089e5839cad1cad7c85" -dependencies = [ - "futures-channel", - "futures-util", - "generational-box", - "longest-increasing-subsequence", - "rustc-hash", - "serde", - "slab", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "dioxus-core-macro" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e29a07448245451334eec2883a394e207f28caedf0a57fd1a903e9ccea0b9531" -dependencies = [ - "constcat", - "convert_case 0.6.0", - "dioxus-rsx", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "dioxus-debug-cell" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea539174bb236e0e7dc9c12b19b88eae3cb574dedbd0252a2d43ea7e6de13e2" - -[[package]] -name = "dioxus-desktop" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85f5008be950e1dd255d947d8037e82e424ccc538892cf251b09a22558dc296" -dependencies = [ - "async-trait", - "cocoa", - "core-foundation", - "dioxus-cli-config", - "dioxus-core", - "dioxus-hooks", - "dioxus-hot-reload", - "dioxus-html", - "dioxus-interpreter-js", - "dunce", - "futures-channel", - "futures-util", - "generational-box", - "global-hotkey", - "infer 0.11.0", - "muda", - "objc", - "objc_id", - "rfd", - "rustc-hash", - "serde", - "serde_json", - "signal-hook", - "slab", - "tao", - "thiserror", - "tokio", - "tracing", - "urlencoding", - "webbrowser", - "wry", -] - -[[package]] -name = "dioxus-fullstack" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7fb1a22ff7af8756bc9506eebfbecf374b1c8c57f087c85c752ba8bd767fce" -dependencies = [ - "async-trait", - "base64", - "bytes", - "ciborium", - "dioxus-desktop", - "dioxus-hot-reload", - "dioxus-lib", - "dioxus_server_macro", - "futures-util", - "once_cell", - "serde", - "serde_json", - "server_fn", - "tracing", -] - -[[package]] -name = "dioxus-hooks" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a8c7019308a6d8381fce84a51006f207407af265aebc5425871399c98d788e4" -dependencies = [ - "dioxus-core", - "dioxus-debug-cell", - "dioxus-signals", - "futures-channel", - "futures-util", - "generational-box", - "slab", - "thiserror", - "tracing", -] - -[[package]] -name = "dioxus-hot-reload" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d5a28a2af6655473c6521fb5a428538807b985e8e5f1a8c30e2ab71bd54e637" -dependencies = [ - "dioxus-core", - "dioxus-html", - "dioxus-rsx", - "interprocess-docfix", - "serde", - "serde_json", -] - -[[package]] -name = "dioxus-html" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d612d9732f32adc1852e13e1387a9d5baa710b0b004641b5123def53065c8d" -dependencies = [ - "async-trait", - "dioxus-core", - "dioxus-html-internal-macro", - "enumset", - "euclid 0.22.10", - "futures-channel", - "generational-box", - "keyboard-types", - "serde", - "serde-value", - "serde_json", - "serde_repr", - "tokio", - "tracing", - "web-sys", -] - -[[package]] -name = "dioxus-html-internal-macro" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1799f34affdb158f6ebec23b46b11f9e65de0bbadbbb781dc68c3eddfe6fd32b" -dependencies = [ - "convert_case 0.6.0", - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "dioxus-interpreter-js" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc68a22e33562317b40ccc7b2d140017e510745c5d7e062e911c6a4f9042e4b1" -dependencies = [ - "dioxus-core", - "dioxus-html", - "md5", - "sledgehammer_bindgen", - "sledgehammer_utils", -] - -[[package]] -name = "dioxus-lib" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9af36a9f985ad68783acf62dc276b0a8f0aa928f1c3b42f78e2ae222b19d445c" -dependencies = [ - "dioxus-core", - "dioxus-core-macro", - "dioxus-hooks", - "dioxus-html", - "dioxus-rsx", - "dioxus-signals", -] - -[[package]] -name = "dioxus-rsx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faa83056104f63fdc6f7f9fc1137208c7b7648bf88d6c86db1e095f15297a0f3" -dependencies = [ - "dioxus-core", - "internment", - "krates", - "proc-macro2", - "quote", - "syn 2.0.66", - "tracing", -] - -[[package]] -name = "dioxus-signals" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd04e2b3739d5c12255005cbf3185446e750fc2b3eeee9fa4e83c989132415c" -dependencies = [ - "dioxus-core", - "futures-channel", - "futures-util", - "generational-box", - "once_cell", - "parking_lot", - "rustc-hash", - "tracing", -] - -[[package]] -name = "dioxus_server_macro" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5ef2cad17001c1155f019cb69adbacd620644566d78a77d0778807bb106a337" -dependencies = [ - "convert_case 0.6.0", - "proc-macro2", - "quote", - "server_fn_macro", - "syn 2.0.66", -] - [[package]] name = "dispatch" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "libc", + "objc2 0.6.4", +] + [[package]] name = "displaydoc" version = "0.2.4" @@ -1182,48 +889,185 @@ dependencies = [ ] [[package]] -name = "dlopen2" -version = "0.7.0" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher", +] + +[[package]] +name = "ecolor" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bdf37f8d5bd9aa7f753573fdda9cf7343afa73dd28d7bfe9593bd9798fc07e" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + +[[package]] +name = "eframe" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14d1c15e7bd136b309bd3487e6ffe5f668b354cd9768636a836dd738ac90eb0b" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glow", + "glutin", + "glutin-winit", + "home", + "image", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "parking_lot", + "percent-encoding", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "web-time", "winapi", + "windows-sys 0.59.0", + "winit", ] [[package]] -name = "dlopen2_derive" -version = "0.4.0" +name = "egui" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" +checksum = "5d5d0306cd61ca75e29682926d71f2390160247f135965242e904a636f51c0dc" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", ] [[package]] -name = "dtoa" -version = "1.0.9" +name = "egui-wgpu" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbb2bf8e87535c23f7a8a321e364ce21462d0ff10cb6407820e8e96dfff6653" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +checksum = "c12eca13293f8eba27a32aaaa1c765bfbf31acd43e8d30d5881dcbe5e99ca0c7" dependencies = [ - "dtoa", + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 1.0.61", + "type-map", + "web-time", + "wgpu", + "winit", ] [[package]] -name = "dunce" -version = "1.0.4" +name = "egui-winit" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56ce8c6da7551ec6c462cbaf3bfbc75131ebbfa1c944aeaa9dab51ca1c5f0c3b" +checksum = "f95d0a91f9cb0dc2e732d49c2d521ac8948e1f0b758f306fb7b14d6f5db3927f" +dependencies = [ + "ahash", + "arboard", + "bytemuck", + "egui", + "log", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "webbrowser", + "winit", +] + +[[package]] +name = "egui_extras" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dddbceddf39805fc6c62b1f7f9c05e23590b40844dc9ed89c6dc6dbc886e3e3b" +dependencies = [ + "ahash", + "egui", + "enum-map", + "log", + "mime_guess2", + "profiling", +] + +[[package]] +name = "egui_glow" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7037813341727937f9e22f78d912f3e29bc3c46e2f40a9e82bb51cbf5e4cfb" +dependencies = [ + "ahash", + "bytemuck", + "egui", + "glow", + "log", + "memoffset", + "profiling", + "wasm-bindgen", + "web-sys", + "winit", +] [[package]] name = "either" @@ -1231,6 +1075,16 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "emath" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45fd7bc25f769a3c198fe1cf183124bf4de3bd62ef7b4f1eaf6b08711a3af8db" +dependencies = [ + "bytemuck", + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1246,6 +1100,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" +[[package]] +name = "enum-map" +version = "2.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.66", +] + [[package]] name = "enumflags2" version = "0.7.10" @@ -1268,26 +1142,41 @@ dependencies = [ ] [[package]] -name = "enumset" -version = "1.1.3" +name = "enumn" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "226c0da7462c13fb57e5cc9e0dc8f0635e7d27f276a3a7fd30054647f669007d" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" dependencies = [ - "enumset_derive", -] - -[[package]] -name = "enumset_derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08b6c6ab82d70f08844964ba10c7babb716de2ecaeab9be5717918a5177d3af" -dependencies = [ - "darling", "proc-macro2", "quote", "syn 2.0.66", ] +[[package]] +name = "epaint" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63adcea970b7a13094fe97a36ab9307c35a750f9e24bf00bb7ef3de573e0fddb" +dependencies = [ + "ab_glyph", + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "serde", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1537accc50c9cab5a272c39300bdd0dd5dca210f6e5e8d70be048df9596e7ca2" + [[package]] name = "equivalent" version = "1.0.1" @@ -1296,14 +1185,20 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.20.14" @@ -1313,16 +1208,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "euclid" -version = "0.22.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0f0eb73b934648cd7a4a61f1b15391cd95dab0b4da6e2e66c2a072c144b4a20" -dependencies = [ - "num-traits", - "serde", -] - [[package]] name = "event-listener" version = "5.3.1" @@ -1362,6 +1247,12 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.4" @@ -1371,16 +1262,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - [[package]] name = "filetime" version = "0.2.27" @@ -1392,17 +1273,11 @@ dependencies = [ "libredox", ] -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - [[package]] name = "flate2" -version = "1.0.30" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -1414,6 +1289,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1459,31 +1340,6 @@ dependencies = [ "libc", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.30" @@ -1491,7 +1347,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -1500,17 +1355,6 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" -[[package]] -name = "futures-executor" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.30" @@ -1541,12 +1385,6 @@ dependencies = [ "syn 2.0.66", ] -[[package]] -name = "futures-sink" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" - [[package]] name = "futures-task" version = "0.3.30" @@ -1559,11 +1397,9 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ - "futures-channel", "futures-core", "futures-io", "futures-macro", - "futures-sink", "futures-task", "memchr", "pin-project-lite", @@ -1571,123 +1407,6 @@ dependencies = [ "slab", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5ba081bdef3b75ebcdbfc953699ed2d7417d6bd853347a42a37d76406a33646" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31ff856cb3386dae1703a920f803abafcc580e9b5f711ca62ed1620c25b51ff2" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkwayland-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90fbf5c033c65d93792192a49a8efb5bb1e640c419682a58bb96f5ae77f3d4a" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2ea8a4909d530f79921290389cbd7c34cb9d623bfe970eaae65ca5f9cd9cce" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fee8f00f4ee46cad2939b8990f5c70c94ff882c3028f3cc5abf950fa4ab53043" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generational-box" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f132919c96b85c02a067ceae965fd50ace57111e2f55c7384b95ac191f4d966b" -dependencies = [ - "parking_lot", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1699,14 +1418,13 @@ dependencies = [ ] [[package]] -name = "getrandom" -version = "0.1.16" +name = "gethostname" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", + "rustix 1.1.4", + "windows-targets 0.52.6", ] [[package]] @@ -1717,206 +1435,135 @@ checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", ] [[package]] -name = "gimli" -version = "0.29.0" +name = "getrandom" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] [[package]] -name = "gio" -version = "0.18.4" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", + "cfg-if", + "js-sys", "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "glow" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "once_cell", - "pin-project-lite", - "smallvec", - "thiserror", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.5.0", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "global-hotkey" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cb13e8c52c87e28a46eae3e5e65b8f0cd465c4c9e67b13d56c70412e792bc3" -dependencies = [ - "bitflags 2.5.0", - "cocoa", - "crossbeam-channel", - "keyboard-types", - "objc", - "once_cell", - "thiserror", + "raw-window-handle", + "wayland-sys", "windows-sys 0.52.0", "x11-dl", ] [[package]] -name = "gloo-net" -version = "0.6.0" +name = "glutin-winit" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" dependencies = [ - "futures-channel", - "futures-core", - "futures-sink", - "gloo-utils", - "http 1.3.1", - "js-sys", - "pin-project", - "serde", - "serde_json", - "thiserror", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", ] [[package]] -name = "gloo-utils" -version = "0.2.0" +name = "glutin_egl_sys" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" dependencies = [ - "js-sys", - "serde", - "serde_json", - "wasm-bindgen", - "web-sys", + "gl_generator", + "windows-sys 0.52.0", ] [[package]] -name = "gobject-sys" -version = "0.18.0" +name = "glutin_glx_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" dependencies = [ - "glib-sys", - "libc", - "system-deps", + "gl_generator", + "x11-dl", ] [[package]] -name = "gtk" -version = "0.18.1" +name = "glutin_wgl_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c4f5e0e20b60e10631a5f06da7fe3dda744b05ad0ea71fee2f47adf865890c" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771437bf1de2c1c0b496c11505bdf748e26066bbe942dfc8f614c9460f6d7722" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6063efb63db582968fb7df72e1ae68aa6360dcfb0a75143f34fc7d616bad75e" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.66", + "gl_generator", ] [[package]] @@ -1927,14 +1574,9 @@ checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ "cfg-if", "crunchy", + "num-traits", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.14.5" @@ -1942,9 +1584,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.9.1" @@ -1954,12 +1610,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1978,6 +1628,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + [[package]] name = "hmac" version = "0.12.1" @@ -1996,42 +1652,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "html5ever" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" -dependencies = [ - "log", - "mac", - "markup5ever", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa 1.0.11", -] - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa 1.0.11", -] - [[package]] name = "iana-time-zone" version = "0.1.65" @@ -2044,7 +1664,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.1", + "windows-core", ] [[package]] @@ -2174,12 +1794,6 @@ dependencies = [ "syn 2.0.66", ] -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.0.0" @@ -2193,32 +1807,27 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "image" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", ] [[package]] name = "indexmap" -version = "2.2.6" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.14.5", -] - -[[package]] -name = "infer" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6c16b11a665b26aeeb9b1d7f954cdeb034be38dd00adab4f2ae921a8fee804" -dependencies = [ - "cfb", + "hashbrown 0.17.1", ] [[package]] @@ -2256,59 +1865,28 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] [[package]] -name = "instant" -version = "0.1.13" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "cfg-if", -] - -[[package]] -name = "internment" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e8e537b529b8674e97e9fb82c10ff168a290ac3867a0295f112061ffbca1ef" -dependencies = [ - "hashbrown 0.14.5", - "parking_lot", -] - -[[package]] -name = "interprocess-docfix" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b84ee245c606aeb0841649a9288e3eae8c61b853a8cd5c0e14450e96d53d28f" -dependencies = [ - "blocking", - "cfg-if", - "futures-core", - "futures-io", - "intmap", - "libc", "once_cell", - "rustc_version", - "spinning", - "thiserror", - "to_method", - "winapi", ] [[package]] -name = "intmap" -version = "0.7.1" +name = "is-wsl" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae52f28f45ac2bc96edb7714de995cffc174a395fb0abf5bff453587c980d7b9" - -[[package]] -name = "itoa" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] [[package]] name = "itoa" @@ -2316,29 +1894,6 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "jni" version = "0.21.1" @@ -2348,19 +1903,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", - "thiserror", + "thiserror 1.0.61", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.19", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.66", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.66", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -2372,10 +1976,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.69" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "once_cell", "wasm-bindgen", ] @@ -2389,15 +1994,10 @@ dependencies = [ ] [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "khronos_api" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.5.0", - "serde", - "unicode-segmentation", -] +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" @@ -2419,43 +2019,27 @@ dependencies = [ "libc", ] -[[package]] -name = "krates" -version = "0.16.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcb3baf2360eb25ad31f0ada3add63927ada6db457791979b82ac199f835cb9" -dependencies = [ - "cargo-platform", - "cargo_metadata", - "cfg-expr", - "petgraph", - "semver", -] - -[[package]] -name = "kuchikiki" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" -dependencies = [ - "cssparser", - "html5ever", - "indexmap 1.9.3", - "matches", - "selectors", -] - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" - [[package]] name = "libc" -version = "0.2.175" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" @@ -2463,7 +2047,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", "libc", "plain", "redox_syscall 0.7.4", @@ -2480,25 +2064,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" -dependencies = [ - "libc", - "x11", -] - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -2511,12 +2076,24 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.12" @@ -2555,15 +2132,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.21" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" - -[[package]] -name = "longest-increasing-subsequence" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "lopdf" @@ -2574,11 +2145,11 @@ dependencies = [ "chrono", "encoding_rs", "flate2", - "itoa 1.0.11", + "itoa", "linked-hash-map", "log", "md5", - "nom", + "nom 7.1.3", "rayon", "time", "weezl", @@ -2586,66 +2157,31 @@ dependencies = [ [[package]] name = "lopdf" -version = "0.34.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" dependencies = [ + "aes", + "bitflags 2.13.1", + "cbc", + "ecb", "encoding_rs", "flate2", - "indexmap 2.2.6", - "itoa 1.0.11", + "getrandom 0.4.3", + "indexmap", + "itoa", "log", "md-5", - "nom", + "nom 8.0.0", + "rand 0.10.2", "rangemap", - "time", + "sha2", + "stringprep", + "thiserror 2.0.19", + "ttf-parser", "weezl", ] -[[package]] -name = "lru" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[package]] -name = "markup5ever" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" -dependencies = [ - "log", - "phf 0.10.1", - "phf_codegen 0.10.0", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - [[package]] name = "md-5" version = "0.10.6" @@ -2668,6 +2204,15 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2693,6 +2238,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "mime_guess2" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca" +dependencies = [ + "mime", + "phf", + "phf_shared", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2701,11 +2258,11 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.7.3" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87dfd01fe195c66b572b37921ad8803d010623c0aca821bea2302239d155cdae" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ - "adler", + "adler2", "simd-adler32", ] @@ -2717,26 +2274,18 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi 0.11.0+wasi-snapshot-preview1", + "wasi", "windows-sys 0.48.0", ] [[package]] -name = "muda" -version = "0.11.5" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c47e7625990fc1af2226ea4f34fb2412b03c12639fcb91868581eb3a6893453" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ - "cocoa", - "crossbeam-channel", - "gtk", - "keyboard-types", - "libxdo", - "objc", - "once_cell", - "png", - "thiserror", - "windows-sys 0.52.0", + "num-traits", + "pxfm", ] [[package]] @@ -2746,17 +2295,42 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" [[package]] -name = "ndk" -version = "0.7.0" +name = "naga" +version = "25.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451422b7e4718271c8b5b3aadf5adedba43dc76312454b387e98fae0fc951aa0" +checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" dependencies = [ - "bitflags 1.3.2", - "jni-sys", + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "strum", + "thiserror 2.0.19", + "unicode-ident", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.0", + "log", "ndk-sys", "num_enum", - "raw-window-handle 0.5.2", - "thiserror", + "raw-window-handle", + "thiserror 1.0.61", ] [[package]] @@ -2767,27 +2341,22 @@ checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "ndk-sys" -version = "0.4.1+23.1.7779620" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf2aae958bd232cac5069850591667ad422d263686d75b52a065f9badeee5a3" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] -[[package]] -name = "new_debug_unreachable" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" - [[package]] name = "nix" -version = "0.27.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", "cfg-if", + "cfg_aliases", "libc", "memoffset", ] @@ -2798,17 +2367,17 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", ] [[package]] -name = "nodrop" -version = "0.1.14" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] name = "nom" @@ -2820,13 +2389,22 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", "crossbeam-channel", "filetime", "fsevent-sys", @@ -2839,16 +2417,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "nu-ansi-term" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" -dependencies = [ - "overload", - "winapi", -] - [[package]] name = "num-conv" version = "0.1.0" @@ -2862,85 +2430,301 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", + "libm", ] [[package]] name = "num_enum" -version = "0.5.11" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", + "rustversion", ] [[package]] name = "num_enum_derive" -version = "0.5.11" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.66", ] [[package]] -name = "objc" -version = "0.2.7" +name = "objc-sys" +version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ - "malloc_buf", - "objc_exception", + "objc-sys", + "objc2-encode", ] [[package]] -name = "objc-foundation" -version = "0.1.1" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "block", - "objc", - "objc_id", + "objc2-encode", ] [[package]] -name = "objc_exception" -version = "0.1.2" +name = "objc2-app-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "cc", + "bitflags 2.13.1", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", ] [[package]] -name = "objc_id" -version = "0.1.1" +name = "objc2-app-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "objc", + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", ] [[package]] -name = "object" -version = "0.36.0" +name = "objc2-cloud-kit" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "memchr", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -2954,17 +2738,28 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "ordered-float" -version = "2.10.1" +name = "open" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" dependencies = [ - "num-traits", + "is-wsl", + "libc", +] + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", ] [[package]] @@ -2978,34 +2773,12 @@ dependencies = [ ] [[package]] -name = "overload" -version = "0.1.1" +name = "owned_ttf_parser" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "ttf-parser", ] [[package]] @@ -3034,7 +2807,7 @@ dependencies = [ "libc", "redox_syscall 0.5.1", "smallvec", - "windows-targets 0.52.5", + "windows-targets 0.52.6", ] [[package]] @@ -3068,14 +2841,16 @@ dependencies = [ [[package]] name = "pdf-extract" -version = "0.7.12" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +checksum = "417e8fdc940f1d5bc62c5f89864c3a2255f74f69aa353c98509213d67df61e73" dependencies = [ "adobe-cmap-parser", + "cff-parser", "encoding_rs", - "euclid 0.20.14", - "lopdf 0.34.0", + "euclid", + "log", + "lopdf 0.42.0", "postscript", "type1-encoding-parser", "unicode-normalization", @@ -3087,106 +2862,48 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset", - "indexmap 2.2.6", -] - [[package]] name = "phf" -version = "0.8.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared 0.8.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_shared 0.10.0", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "phf_shared", ] [[package]] name = "phf_generator" -version = "0.8.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", + "phf_shared", "rand 0.8.5", ] [[package]] name = "phf_macros" -version = "0.8.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", - "proc-macro-hack", + "phf_generator", + "phf_shared", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.66", + "unicase", ] [[package]] name = "phf_shared" -version = "0.8.0" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", + "unicase", ] [[package]] @@ -3246,11 +2963,11 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "png" -version = "0.17.13" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e4b0d3d1312775e782c86c91a111aa1f910cbb65e1337f9975b5f9a554b5e1" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -3267,16 +2984,16 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 0.38.34", "tracing", "windows-sys 0.52.0", ] [[package]] name = "pollster" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "pom" @@ -3284,6 +3001,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "postscript" version = "0.14.1" @@ -3302,81 +3025,42 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f12335488a2f3b0a83b14edad48dca9879ce89b2edd10e80237e4e852dd645e" -dependencies = [ - "proc-macro2", - "syn 2.0.66", -] - [[package]] name = "proc-macro-crate" -version = "1.3.1" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "once_cell", - "toml_edit 0.19.15", + "toml_edit 0.25.13+spec-1.1.0", ] -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - [[package]] name = "proc-macro2" -version = "1.0.85" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.31.0" @@ -3386,24 +3070,36 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quicksearch-core" version = "0.1.0" dependencies = [ "ctrlc", - "infer 0.15.0", + "globset", + "infer", "kamadak-exif", "lofty", "lopdf 0.32.0", "mime_guess", "notify", "pdf-extract", - "quick-xml", + "quick-xml 0.31.0", + "regex", "rusqlite", "serde", "sha2", "toml", "walkdir", + "windows-sys 0.52.0", "zip", "zstd 0.13.3", ] @@ -3412,10 +3108,14 @@ dependencies = [ name = "quicksearch-gui" version = "0.1.0" dependencies = [ - "dioxus", - "dioxus-desktop", + "chrono", + "eframe", + "egui", + "egui_extras", + "open", "quicksearch-core", - "tokio", + "rfd", + "windows-sys 0.59.0", ] [[package]] @@ -3428,18 +3128,16 @@ dependencies = [ ] [[package]] -name = "rand" -version = "0.7.3" +name = "r-efi" +version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg", -] +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -3447,38 +3145,38 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "libc", - "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "rand" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] name = "rand_chacha" -version = "0.3.1" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", + "rand_core 0.9.5", ] [[package]] @@ -3486,27 +3184,21 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.2.15", + "getrandom 0.3.4", ] [[package]] -name = "rand_hc" -version = "0.2.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rangemap" @@ -3514,12 +3206,6 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" -[[package]] -name = "raw-window-handle" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3546,13 +3232,22 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469052894dcb553421e483e4209ee581a45100d31b4018de03e5a7ad86374a7e" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", ] [[package]] @@ -3561,14 +3256,14 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", ] [[package]] name = "regex" -version = "1.10.5" +version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" dependencies = [ "aho-corasick", "memchr", @@ -3594,26 +3289,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" [[package]] -name = "rfd" -version = "0.14.1" +name = "renderdoc-sys" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a73a7337fc24366edfca76ec521f51877b114e42dab584008209cca6719251" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" dependencies = [ "ashpd", - "block", - "dispatch", + "block2 0.6.2", + "dispatch2", "js-sys", "log", - "objc", - "objc-foundation", - "objc_id", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "pollster", - "raw-window-handle 0.6.2", + "raw-window-handle", "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.48.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "ron" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beceb6f7bf81c73e73aeef6dd1356d9a1b2b4909e1f0fc3e59b034f9572d7b7f" +dependencies = [ + "base64", + "bitflags 2.13.1", + "serde", + "serde_derive", + "unicode-ident", ] [[package]] @@ -3622,7 +3337,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3630,18 +3345,18 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - [[package]] name = "rustc-hash" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.0" @@ -3657,18 +3372,31 @@ version = "0.38.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" dependencies = [ - "bitflags 2.5.0", + "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] [[package]] -name = "ryu" -version = "1.0.18" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -3679,100 +3407,52 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "selectors" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "matches", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", - "thin-slice", -] - [[package]] name = "semver" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" -dependencies = [ - "serde", -] - -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" -dependencies = [ - "futures-core", -] [[package]] name = "serde" -version = "1.0.203" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_derive" -version = "1.0.203" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.66", -] - -[[package]] -name = "serde_json" -version = "1.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" -dependencies = [ - "itoa 1.0.11", - "ryu", - "serde", -] - -[[package]] -name = "serde_qs" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0431a35568651e363364210c91983c1da5eb29404d9f0928b67d4ebcfa7d330c" -dependencies = [ - "percent-encoding", - "serde", - "thiserror", + "syn 3.0.3", ] [[package]] @@ -3795,68 +3475,6 @@ dependencies = [ "serde", ] -[[package]] -name = "server_fn" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fae7a3038a32e5a34ba32c6c45eb4852f8affaf8b794ebfcd4b1099e2d62ebe" -dependencies = [ - "bytes", - "const_format", - "dashmap", - "futures", - "gloo-net", - "http 1.3.1", - "js-sys", - "once_cell", - "send_wrapper", - "serde", - "serde_json", - "serde_qs", - "server_fn_macro_default", - "thiserror", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "xxhash-rust", -] - -[[package]] -name = "server_fn_macro" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faaaf648c6967aef78177c0610478abb5a3455811f401f3c62d10ae9bd3901a1" -dependencies = [ - "const_format", - "convert_case 0.6.0", - "proc-macro2", - "quote", - "syn 2.0.66", - "xxhash-rust", -] - -[[package]] -name = "server_fn_macro_default" -version = "0.6.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f2aa8119b558a17992e0ac1fd07f080099564f24532858811ce04f742542440" -dependencies = [ - "server_fn_macro", - "syn 2.0.66", -] - -[[package]] -name = "servo_arc" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - [[package]] name = "sha1" version = "0.10.6" @@ -3864,7 +3482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest", ] @@ -3875,29 +3493,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.12", "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "signal-hook" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -3914,10 +3513,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" [[package]] -name = "siphasher" -version = "0.3.11" +name = "simd_cesu8" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -3929,33 +3544,12 @@ dependencies = [ ] [[package]] -name = "sledgehammer_bindgen" -version = "0.5.0" +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcfaf791ff02f48f3518ce825d32cf419c13a43c1d8b1232f74ac89f339c46d2" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ - "sledgehammer_bindgen_macro", -] - -[[package]] -name = "sledgehammer_bindgen_macro" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd941cc539bd3dc694edaf9d0c4e1221d02baa67c6b45ec04fad1024d9e8139" -dependencies = [ - "quote", - "syn 2.0.66", -] - -[[package]] -name = "sledgehammer_utils" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f20798defa0e9d4eff9ca451c7f84774c7378a9c3b5a40112cfa2b3eadb97ae2" -dependencies = [ - "lru", - "once_cell", - "rustc-hash", + "version_check", ] [[package]] @@ -3965,38 +3559,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] -name = "soup3" -version = "0.5.0" +name = "smithay-client-toolkit" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "futures-channel", - "gio", - "glib", + "bitflags 2.13.1", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", "libc", - "soup3-sys", + "log", + "memmap2", + "rustix 0.38.34", + "thiserror 1.0.61", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "soup3-sys" -version = "0.5.0" +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", + "bitflags 2.13.1", + "calloop 0.14.4", + "calloop-wayland-source 0.4.1", + "cursor-icon", "libc", - "system-deps", + "log", + "memmap2", + "rustix 1.1.4", + "thiserror 2.0.19", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "spinning" -version = "0.1.0" +name = "smithay-clipboard" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d4f0e86297cad2658d92a707320d87bf4e6ae1050287f51d19b67ef3f153a7b" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" dependencies = [ - "lock_api", + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", ] [[package]] @@ -4012,29 +3643,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "string_cache" -version = "0.8.7" +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "new_debug_unreachable", - "once_cell", - "parking_lot", - "phf_shared 0.10.0", - "precomputed-hash", - "serde", + "unicode-bidi", + "unicode-normalization", + "unicode-properties", ] [[package]] -name = "string_cache_codegen" -version = "0.5.2" +name = "strum" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", "proc-macro2", "quote", + "rustversion", + "syn 2.0.66", ] [[package]] @@ -4045,9 +3683,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "1.0.109" +version = "2.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" dependencies = [ "proc-macro2", "quote", @@ -4056,9 +3694,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.66" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -4076,74 +3714,6 @@ dependencies = [ "syn 2.0.66", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml", - "version-compare", -] - -[[package]] -name = "tao" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ebbccb78deb5a36744c079eea2981b4a48ecbbe6b1b2ffbaa528bea3f5e5db" -dependencies = [ - "bitflags 1.3.2", - "cocoa", - "core-foundation", - "core-graphics", - "crossbeam-channel", - "dispatch", - "dlopen2", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "instant", - "jni", - "lazy_static", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc", - "once_cell", - "parking_lot", - "raw-window-handle 0.5.2", - "raw-window-handle 0.6.2", - "scopeguard", - "tao-macros", - "unicode-segmentation", - "url", - "windows 0.54.0", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec114582505d158b669b136e6851f85840c109819d77c42bb7c0709f727d18c2" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "target-lexicon" -version = "0.12.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1fc403891a21bcfb7c37834ba66a547a8f402146eba7265b5a6d88059c9ff2f" - [[package]] name = "tempfile" version = "3.10.1" @@ -4152,34 +3722,35 @@ checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", - "rustix", + "rustix 0.38.34", "windows-sys 0.52.0", ] [[package]] -name = "tendril" -version = "0.4.3" +name = "termcolor" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ - "futf", - "mac", - "utf-8", + "winapi-util", ] -[[package]] -name = "thin-slice" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" - [[package]] name = "thiserror" version = "1.0.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.61", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", ] [[package]] @@ -4194,13 +3765,28 @@ dependencies = [ ] [[package]] -name = "thread_local" -version = "1.1.8" +name = "thiserror-impl" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ - "cfg-if", - "once_cell", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", ] [[package]] @@ -4258,40 +3844,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "to_method" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c4ceeeca15c8384bbc3e011dbd8fccb7f068a440b752b7d9b32ceb0ca0e2e8" - -[[package]] -name = "tokio" -version = "1.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "num_cpus", - "pin-project-lite", - "signal-hook-registry", - "tokio-macros", - "windows-sys 0.48.0", -] - -[[package]] -name = "tokio-macros" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", -] - [[package]] name = "toml" version = "0.8.2" @@ -4300,7 +3852,7 @@ checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.3", "toml_edit 0.20.2", ] @@ -4314,14 +3866,12 @@ dependencies = [ ] [[package]] -name = "toml_edit" -version = "0.19.15" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "indexmap 2.2.6", - "toml_datetime", - "winnow", + "serde_core", ] [[package]] @@ -4330,11 +3880,32 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.2.6", + "indexmap", "serde", "serde_spanned", - "toml_datetime", - "winnow", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", ] [[package]] @@ -4343,6 +3914,7 @@ version = "0.1.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4366,32 +3938,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" dependencies = [ "once_cell", - "valuable", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "ttf-parser" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] -name = "tracing-subscriber" -version = "0.3.18" +name = "type-map" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", + "rustc-hash 2.1.3", ] [[package]] @@ -4426,6 +3987,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.12" @@ -4442,16 +4009,22 @@ dependencies = [ ] [[package]] -name = "unicode-segmentation" -version = "1.11.0" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-segmentation" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "url" @@ -4471,12 +4044,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf16_iter" version = "1.0.5" @@ -4495,24 +4062,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0" -[[package]] -name = "valuable" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" - [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version-compare" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b" - [[package]] name = "version_check" version = "0.9.4" @@ -4529,12 +4084,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -4542,35 +4091,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "wasm-bindgen" -version = "0.2.92" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "cfg-if", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.92" +name = "wasm-bindgen" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ - "bumpalo", - "log", + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn 2.0.66", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" dependencies = [ "cfg-if", "js-sys", @@ -4580,9 +4126,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.92" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4590,41 +4136,176 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.92" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.66", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.92" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] [[package]] -name = "wasm-streams" -version = "0.4.1" +name = "wayland-backend" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e072d4e72f700fb3443d8fe94a39315df013eef1104903cdb0a2abd322bbecd" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", ] [[package]] name = "web-sys" -version = "0.3.69" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", @@ -4632,107 +4313,123 @@ dependencies = [ [[package]] name = "webbrowser" -version = "0.8.15" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db67ae75a9405634f5882791678772c94ff5f16a66535aae186e26aa0841fc8b" +checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" dependencies = [ - "core-foundation", - "home", - "jni", + "core-foundation 0.10.1", + "jni 0.21.1", "log", "ndk-context", - "objc", - "raw-window-handle 0.5.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", "url", "web-sys", ] -[[package]] -name = "webkit2gtk" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webview2-com" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0ae9c7e420783826cf769d2c06ac9ba462f450eca5893bb8c6c6529a4e5dd33" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.52.0", - "windows-core 0.52.0", - "windows-implement 0.52.0", - "windows-interface 0.52.0", -] - -[[package]] -name = "webview2-com-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac1345798ecd8122468840bcdf1b95e5dc6d2206c5e4b0eafa078d061f59c9bc" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "webview2-com-sys" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ad85fceee6c42fa3d61239eba5a11401bf38407a849ed5ea1b407df08cca72" -dependencies = [ - "thiserror", - "windows 0.52.0", - "windows-core 0.52.0", -] - [[package]] name = "weezl" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wgpu" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8fb398f119472be4d80bc3647339f56eb63b2a331f6a3d16e25d8144197dd9" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b882196f8368511d613c6aeec80655160db6646aebddf8328879a88d54e500" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.19", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba5fb5f7f9c98baa7c889d444f63ace25574833df56f5b817985f641af58e46" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "25.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f968767fe4d3d33747bbd1473ccd55bf0f6451f55d733b5597e67b5deab4ad17" +dependencies = [ + "bitflags 2.13.1", + "cfg_aliases", + "libloading", + "log", + "naga", + "parking_lot", + "portable-atomic", + "raw-window-handle", + "renderdoc-sys", + "thiserror 2.0.19", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "25.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aa49460c2a8ee8edba3fca54325540d904dd85b2e086ada762767e17d06e8bc" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "thiserror 2.0.19", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4764,84 +4461,19 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-implement 0.52.0", - "windows-interface 0.52.0", - "windows-targets 0.52.5", -] - -[[package]] -name = "windows" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" -dependencies = [ - "windows-core 0.54.0", - "windows-implement 0.53.0", - "windows-interface 0.53.0", - "windows-targets 0.52.5", -] - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.5", -] - -[[package]] -name = "windows-core" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" -dependencies = [ - "windows-result 0.1.2", - "windows-targets 0.52.5", -] - [[package]] name = "windows-core" version = "0.62.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows-implement", + "windows-interface", "windows-link", - "windows-result 0.4.0", + "windows-result", "windows-strings", ] -[[package]] -name = "windows-implement" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "windows-implement" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942ac266be9249c84ca862f0a164a39533dc2f6f33dc98ec89c8da99b82ea0bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -4853,28 +4485,6 @@ dependencies = [ "syn 2.0.66", ] -[[package]] -name = "windows-interface" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", -] - -[[package]] -name = "windows-interface" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da33557140a288fae4e1d5f8873aaf9eb6613a9cf82c3e070223ff177f598b60" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.66", -] - [[package]] name = "windows-interface" version = "0.59.3" @@ -4892,15 +4502,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.5", -] - [[package]] name = "windows-result" version = "0.4.0" @@ -4943,7 +4544,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.5", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -4987,27 +4597,18 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.5", - "windows_aarch64_msvc 0.52.5", - "windows_i686_gnu 0.52.5", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.5", - "windows_x86_64_gnu 0.52.5", - "windows_x86_64_gnullvm 0.52.5", - "windows_x86_64_msvc 0.52.5", -] - -[[package]] -name = "windows-version" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6998aa457c9ba8ff2fb9f13e9d2a930dabcea28f1d0ab94d687d8b3654844515" -dependencies = [ - "windows-targets 0.52.5", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -5024,9 +4625,9 @@ checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_msvc" @@ -5042,9 +4643,9 @@ checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_aarch64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" @@ -5060,15 +4661,15 @@ checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" @@ -5084,9 +4685,9 @@ checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_i686_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" @@ -5102,9 +4703,9 @@ checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnu" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnullvm" @@ -5120,9 +4721,9 @@ checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" @@ -5138,9 +4739,60 @@ checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "windows_x86_64_msvc" -version = "0.52.5" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.34", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] [[package]] name = "winnow" @@ -5151,6 +4803,30 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "write16" version = "1.0.0" @@ -5163,61 +4839,6 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" -[[package]] -name = "wry" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b717040ba9771fd88eb428c6ea6b555f8e734ff8534f02c13e8f10d97f5935e" -dependencies = [ - "base64", - "block", - "cfg_aliases 0.1.1", - "cocoa", - "core-graphics", - "crossbeam-channel", - "dunce", - "gdkx11", - "gtk", - "html5ever", - "http 0.2.12", - "javascriptcore-rs", - "jni", - "kuchikiki", - "libc", - "log", - "ndk", - "ndk-context", - "ndk-sys", - "objc", - "objc_id", - "once_cell", - "percent-encoding", - "raw-window-handle 0.6.2", - "serde", - "serde_json", - "sha2", - "soup3", - "tao-macros", - "thiserror", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.52.0", - "windows-implement 0.52.0", - "windows-version", - "x11-dl", -] - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - [[package]] name = "x11-dl" version = "2.21.0" @@ -5229,6 +4850,33 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + [[package]] name = "xdg-home" version = "1.2.0" @@ -5240,10 +4888,29 @@ dependencies = [ ] [[package]] -name = "xxhash-rust" -version = "0.8.15" +name = "xkbcommon-dl" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" @@ -5271,9 +4938,9 @@ dependencies = [ [[package]] name = "zbus" -version = "4.0.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b8e3d6ae3342792a6cc2340e4394334c7402f3d793b390d2c5494a4032b3030" +checksum = "cbddd8b6cb25d5d8ec1b23277b45299a98bfb220f1761ca11e186d5c702507f8" dependencies = [ "async-broadcast", "async-executor", @@ -5285,23 +4952,20 @@ dependencies = [ "async-task", "async-trait", "blocking", - "derivative", "enumflags2", "event-listener", "futures-core", - "futures-sink", "futures-util", "hex", - "nix 0.27.1", + "nix 0.29.0", "ordered-stream", - "rand 0.8.5", "serde", "serde_repr", - "sha1", "static_assertions", "tracing", "uds_windows", - "windows-sys 0.52.0", + "windows-sys 0.59.0", + "winnow 0.7.15", "xdg-home", "zbus_macros", "zbus_names", @@ -5310,26 +4974,27 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "4.0.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7a3e850ff1e7217a3b7a07eba90d37fe9bb9e89a310f718afcde5885ca9b6d7" +checksum = "dac404d48b4e9cf193c8b49589f3280ceca5ff63519e7e64f55b4cf9c47ce146" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", - "regex", - "syn 1.0.109", + "syn 2.0.66", + "zbus_names", + "zvariant", "zvariant_utils", ] [[package]] name = "zbus_names" -version = "3.0.0" +version = "4.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" dependencies = [ "serde", - "static_assertions", + "winnow 1.0.4", "zvariant", ] @@ -5464,39 +5129,57 @@ dependencies = [ ] [[package]] -name = "zvariant" -version = "4.0.0" +name = "zune-core" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e09e8be97d44eeab994d752f341e67b3b0d80512a8b315a0671d47232ef1b65" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911" dependencies = [ "endi", "enumflags2", "serde", - "static_assertions", "url", + "winnow 1.0.4", "zvariant_derive", + "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "4.0.0" +version = "5.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a5857e2856435331636a9fbb415b09243df4521a267c5bedcd5289b4d5799e" +checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate", "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.66", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "1.1.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bedb16a193cc12451873fee2a1bc6550225acece0e36f333e68326c73c8172" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "serde", + "syn 2.0.66", + "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index 6535d4c..c69b97f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,5 +6,8 @@ members = [ ] [workspace.package] -version = "0.1.0" +version = "0.8.0" edition = "2021" +license = "GPL-3.0-or-later" +authors = ["Jeremy "] +repository = "https://code.karsttech.com/jeremy/quick_search.git" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..40ddd2f --- /dev/null +++ b/README.md @@ -0,0 +1,312 @@ +# QuickSearch + +A fast local file indexer and search tool. QuickSearch walks your chosen +folders into a compact SQLite index (FTS5 full-text + zstd-compressed text +sidecar), keeps it fresh automatically with filesystem watchers and +periodic reindexing, and serves ranked search-as-you-type results in a +compact egui desktop app, or straight to your terminal. + +## Build & run + +Requirements: a Rust toolchain (edition 2021). The old WebKit/WebView +dependencies (`setup.sh`) are gone; the GUI renders with OpenGL via egui. + +SQLite and zstd are compiled from bundled C sources, so a C toolchain is +required on every platform: + +- Linux: working OpenGL 3.3 drivers; `xdg-desktop-portal` (present on all + mainstream desktops) provides the native folder picker. On minimal + images you may need `build-essential pkg-config libxkbcommon-dev`. +- Windows: Visual Studio 2022 Build Tools with the "Desktop development + with C++" workload (MSVC v143 plus a Windows SDK). 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 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. + +```sh +cargo build --release -p quicksearch-gui # binaries: target/release/quicksearch{,-cli} +cargo run -p quicksearch-gui # or just run it +cargo test -p quicksearch-core # backend test suite +``` + +`run.sh` / `run.bat` wrap the same commands. + +Two binaries are produced. `quicksearch` is the desktop app; on Windows it +is built as a window-subsystem app so no console appears behind it. +`quicksearch-cli` is terminal search — a console app, so pipes, redirection +and exit codes behave normally. On Unix `quicksearch` also does both, and +`quicksearch-cli` is simply the same tool under a clearer name. + +## Install (Debian / Ubuntu) + +```sh +./packaging/build-deb.sh +sudo apt install ./dist/quicksearch_0.1.0-1_amd64.deb +``` + +The script builds the release binary, strips it, and assembles a `.deb` with +`dpkg-deb`. It needs no `cargo-deb`, no `debhelper` and no SVG rasteriser — +only `dpkg-deb` and `desktop-file-utils`, both standard on Debian and Ubuntu. +Useful flags: `--no-build` to package a binary you already built, `--no-strip` +to keep debug symbols, `-o DIR` to write elsewhere. `DEB_REVISION` and +`DEB_MAINTAINER` override the packaging revision and maintainer. + +The package installs: + +| Path | Contents | +| --- | --- | +| `/usr/bin/quicksearch` | the desktop app, which also does terminal search | +| `/usr/bin/quicksearch-cli` | terminal search only | +| `/usr/share/applications/quicksearch.desktop` | menu entry, so QuickSearch appears in the app launcher | +| `/usr/share/icons/hicolor/{16,22,24,32,48,64,128,256}x*/apps/` | icons at each size | +| `/usr/share/icons/hicolor/scalable/apps/quicksearch.svg` | the source icon | +| `/usr/share/man/man1/quicksearch{,-cli}.1.gz` | `man quicksearch`; the `-cli` page is a `.so` stub pointing at it | +| `/usr/share/doc/quicksearch/` | copyright, changelog, README, `config_example.toml` | + +Installing registers the menu entry and the icon: dpkg triggers owned by +`desktop-file-utils` and `hicolor-icon-theme` refresh both caches, so no +maintainer scripts are involved and `apt remove` reverses it cleanly. + +No `config.toml` is installed. One placed next to the executable would put +every user into portable mode (see [Configuration](#configuration)); instead +the app writes `~/.config/quicksearch/config.toml` on first run. + +### Icons + +`crates/quicksearch-gui/assets/icons/` holds `quicksearch_icon.svg` and the +PNGs rasterised from it. The PNGs are committed rather than generated, so an +ordinary `cargo build` needs no image tooling — the 256px one is compiled into +the binary with `include_bytes!` and becomes the window icon. Editing the SVG +means re-rendering the PNGs; `packaging/build-deb.sh` documents how in a +comment at the top. + +X11 takes the window icon from the embedded PNG. Wayland ignores it and +matches the app id (`quicksearch`) against the installed +`quicksearch.desktop`, so under Wayland the titlebar icon appears only once +the package is installed. + +## Usage + +### GUI + +`quicksearch` with no query arguments opens the app: + +- **Search**: results appear as you type; every keystroke cancels the + previous search. One checkbox enables the two fuzzy passes. Sort by + rank, name, path, size, or modified; right-click a result to open it, + reveal it in the file manager, or build an ignore filter from it + (session-only by default, optionally persisted to the config). Matches + in file contents show highlighted snippets. +- **Manage Index**: full indexing status, Start/Stop/Automatic controls, + indexed folder list, full-text extension filters, ignore patterns, and + the indexing options. +- **Duplicates**: files sharing a content hash, grouped. +- **Logs**: the lines the app would have printed to a terminal — warnings + from indexing, folder watching and opening files, newest last, with a + filter box and Copy button. Launched from a desktop launcher (or on + Windows, where the app has no console at all) this is the only place + they are visible. + +The bottom status bar always shows what the indexer is doing (phase, +percent, files/sec) or the total indexed file count when idle. + +### Terminal + +```sh +quicksearch report type:Document modified:">=2024-01-01" +quicksearch --long --limit 20 "quarterly budget" +quicksearch --fuzzy repot # tolerates typos +``` + +Prints rank-ordered paths (pipe-friendly); `--long` adds rank, size, +mtime, and highlighted snippets. `quicksearch --help` shows all flags. + +On Windows use `quicksearch-cli` for all of the above — `quicksearch.exe` +opens the app, and any query given to it seeds the search box instead of +printing. Colour in `--long` output needs a console with virtual-terminal +processing; Windows Terminal has it, and older consoles get plain text. + +### Query syntax + +This section is the complete reference (the in-app "?" popup shows a +condensed version of the same rules). Everything that isn't a filter is +matched as one phrase, in order. Filters combine freely with the search +text: + +| Syntax | Meaning | +|---|---| +| `budget report` | names, contents, and paths containing the phrase `budget report` | +| `"exact phrase"` | quotes keep spaces, stars, and filter-like words literal; `""` escapes a quote | +| `bud*port` | `*` matches any run of characters (it stays on one line of content); `%` and `_` are always literal | +| `regex:"(foo|bar)\d+"` | regular expression matched against names, contents, and paths; case-insensitive by default (`(?-i:…)` overrides); quote patterns containing spaces or `( ) : = < > "` | +| `type:Audio` | one of Audio, Image, Video, Document, Text, Archive, Spreadsheet, Presentation, Folder | +| `modified:>=2024-01-01` | also `<`, `<=`, `>`, `=` (dates are `yyyy-mm-dd`; `mtime:` is an alias) | +| `path:/home/me/docs` | restrict to a folder and its subfolders (`folder:` and `includefolder:` are aliases); `*` is literal here | +| `path:C:\Users\me\docs` | the same on Windows — drive letters and backslashes need no quoting | +| `mime:application/pdf` | exact MIME type | +| `name:re*.txt` | filename contains (as a filter, unranked; `filename:` is an alias); unquoted `*` globs | + +Unrecognized `key:value` text (like `12:30`) stays part of the search +phrase, and a half-typed quote never errors while you type. `AND`, `OR` +and parentheses are treated as plain words. A term of only stars matches +nothing, and a regex that could match the empty string is rejected rather +than matching every file. `regex:` bypasses the trigram index entirely +and combines with filters; alongside search text it acts as an extra +requirement on those results. + +The search box highlights this syntax as you type: recognized filter +keywords in red, their arguments in blue, syntax characters (operators, +quotes, live wildcards) in green, on a tinted chip per complete filter. +An argument the engine would reject — unknown `type:` name, bad date, +invalid regex — switches to the error color immediately. + +Results are ranked: exact filename matches (case-sensitive first), then +filename substrings, then full-text matches ordered by occurrence count, +then fuzzy filename/full-text matches when enabled, and last the files +matched somewhere else in their path. Later, weaker matches only ever +append to the bottom of the list. Wildcard terms rank through the same +tiers (an "exact" match means the whole name matches the pattern) but +skip the fuzzy passes; regex-only queries reuse the substring, full-text, +and path tiers. Path matching needs at least three characters, and terms +may span separators (`docs/report`). Full-text matching also needs at +least three characters of literal text (the trigram floor). The fuzzy +passes tolerate typos with a budget of one edit per three characters, +capped by `[search] fuzzy_max_edits` (default 2; 0 turns fuzzy off). + +### Configuration + +`config.toml` lives at `~/.config/quicksearch/config.toml` (Windows: +`%APPDATA%\quicksearch\config.toml`) and is created on first run; the +default index goes to `~/.local/share/quicksearch/index.sqlite` +(Windows: `%LOCALAPPDATA%\quicksearch\index.sqlite`). See +`config_example.toml` for every option. + +Defaults follow the platform. The first indexing root is your home +directory or `%USERPROFILE%`; `include_hidden = false` skips dot-files +everywhere and additionally anything marked Hidden or System on Windows, +which is what keeps `AppData`, `$RECYCLE.BIN` and `System Volume +Information` out of the index; and ignore patterns are matched +case-insensitively on Windows and macOS, matching the filesystem. + +**Portable mode**: a `config.toml` sitting next to the `quicksearch` +binary overrides the user config entirely, and relative paths inside any +config resolve against the config file's own directory, so a folder +containing the binary, its config, and its index can be moved wholesale. + +The GUI edits the config live; external edits apply on next start. + +## Engineering overview + +Two crates: + +``` +crates/quicksearch-core library: indexing, storage, search +crates/quicksearch-gui binary "quicksearch": egui app + terminal mode +``` + +### Backend (`quicksearch-core`) + +Synchronous Rust: `std::thread` + `mpsc` channels, no async runtime. + +- **Storage** (`db/`): SQLite via rusqlite (bundled), WAL mode so the + single writer never blocks streaming read-only searches. `files` holds + metadata (name, path, size, mtime, hash, MIME/type bitmask, per-row + index state); `searchabletext` is a *contentless* FTS5 table (postings + only, configurable tokenizer, trigram by default); canonical extracted + text lives zstd-compressed in `documents_text`, which powers snippets, + occurrence ranking, and fuzzy full-text search. Schema changes wipe and + rebuild by policy; the indexer (`open_or_recreate`) is the only code + allowed to do that; every consumer uses `open_existing`, which treats + drift as an error, never data loss. +- **Indexing** (`indexing.rs`, `file_handling.rs`): full runs walk each + root (`filtered_walk` prunes hidden/ignored subtrees before descending), + classify files by mtime into insert/update/skip, batch-write metadata, + sweep stale rows, then extract content (plaintext, Office, PDF, audio + tags, EXIF; see `extract/`) for FTS. Files no larger than + `processing.hash_length` skip that second pass entirely: the head the walk + reads to hash them is already their whole content, so a plaintext body is + extracted in the same `read` and stored complete. Progress streams through + a polled `IndexingStatus`. +- **Coordinator** (`coordinator.rs`): the object binaries construct. + Owns the `IndexingService`, the debouncing filesystem watcher + (`watcher.rs`), and the mode state machine (Auto / Manual). Watcher + events become single-file transactions (`incremental.rs`) that keep + `files`, FTS, and the text sidecar consistent per commit; a full + reindex runs on a configurable interval. Incremental writes defer while + a full run is active, so there is exactly one writer at a time. + Registration follows what the platform's notification API can do: + inotify covers one directory per watch, so the roots are walked and each + surviving directory registered individually (skipping `.git`, + `node_modules` and hidden subtrees, which is what keeps the watch count + affordable), while `ReadDirectoryChangesW` covers a whole tree from one + handle and takes a single watch per root, filtering the events instead. + Either way a tree too large to watch degrades to periodic reindexing + rather than going silently stale. +- **Search** (`search/`): `SearchService` runs one worker thread; each + query is a *generation*. New queries interrupt the in-flight SQLite + statement (`InterruptHandle`) and stale generations stop cooperatively, + so typing never waits. The cascade streams rank-ordered batches: one + `files` scan classifies exact/case/substring filename matches (ranks + 1–4) and, since a path contains its own name, sets aside full-path + matches from the same rows (ranks 9–10); one FTS phrase probe verified + against the decompressed text yields full-text ranks 5–6 ordered by + occurrence count; and the opt-in fuzzy passes run a bitap (Wu–Manber) + matcher over filenames (rank 7), document text (rank 8) and paths + (rank 11), with a configurable edit budget. The deferred path tiers + flush last, so weaker matches only ever append. All SQL is + parameterized; structured filters from the query language (`query/`) + are ANDed onto every pass. +- **Baloo compatibility** (`cli.rs`, `mime.rs`): read-only endpoints + (`status_for_path`, `list_failed`, `index_size_breakdown`, …) and a + Baloo-shaped type model, groundwork for a future `balooctl`-compatible + layer. +- **Logging** (`log.rs`): background reporting goes through `log_info!` / + `log_warn!` rather than `println!`/`eprintln!`. Each writes its line to + stderr *and* appends it to a bounded in-memory ring (newest 5000 lines, + with a count of what was dropped) that the GUI's Logs tab reads, so a + windowed run with no terminal still surfaces them. Command output — + search hits, usage, the error a command exits with — stays on stdio. +- **Platform differences** (`platform.rs`): the single home for `#[cfg]`. + Home directory lookup, what counts as a hidden entry (dot-prefix, plus + the Hidden/System attributes on Windows), network-filesystem detection + (`/proc/mounts` against `GetDriveTypeW`), path collation, and the + watch-registration strategy all live here, so the rest of the crate can + ask a question rather than test a target. Anything decidable from a + string alone is split out so its tests run on every platform. + +### Frontend (`quicksearch-gui`) + +Immediate-mode egui/eframe app, one UI thread: + +``` +UI thread ──SearchRequest──▶ search worker ──SearchUpdate (mpsc)──▶ drained per frame +UI thread ──commands──────▶ IndexCoordinator ──state──▶ polled per frame +core threads ─────────────▶ ctx.request_repaint() (wake the UI) +``` + +Modules map one-to-one onto what you see: `app.rs` (shell, status bar, +config routing), `search_tab.rs` (query strip, virtualized results table, +snippet highlighting via `LayoutJob` byte ranges, ignore dialog), +`manage_tab.rs` (status detail + `tracker.rs` rate estimation, roots and +filter editors), `duplicates_tab.rs`, `logs_tab.rs` (a virtualized view of +the core log ring), `options.rs` (draft-based settings +editor shared between the window and the Manage tab), `platform.rs` +(open / reveal-in-file-manager, and the Windows stdio setup a +window-subsystem process needs before anything prints), `cli.rs` (terminal +mode, shared with the `quicksearch-cli` binary). There is no +pagination: the table is virtualized, so a single scroll list capped at +`display_limit` renders in microseconds regardless of row count. + +## Development + +- `cargo test -p quicksearch-core`: unit + integration suites (cascade + ranking, cancellation, incremental indexing, coordinator modes, config + resolution, fuzzy matcher vs. brute-force oracle). +- `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. +- New extractors: implement `extract::Extractor` and register it in + `Registry::default_set()`. New cascade behavior: `search/cascade.rs` + documents the rank invariants that keep streamed results append-only. diff --git a/config_example.toml b/config_example.toml index bdcccd4..00514f4 100644 --- a/config_example.toml +++ b/config_example.toml @@ -1,37 +1,112 @@ -[paths] -# One or more directory roots to index. Walked in order; duplicate and -# nested roots are de-duplicated automatically. -indexing_paths = ["/"] -database_path = "QuickSearch.db" - -[processing] -# Amount of data in bytes read from start/end of files used to calculate hash -hash_length = 8192 -# Maximum text content to index per file (bytes) -maximum_text_size = 262144 -# Maximum file size to process for text extraction (bytes) -maximum_text_file_size = 2097152 -# Number of files to process in each batch (directory walk / inserts / text extraction batches) -batch_size = 200 -# Files per transaction for incremental UPDATE files + DELETE from searchabletext (FTS); larger = fewer commits, more RAM per chunk -fts_update_batch_size = 1000 -# If true, run a fast shell-backed tree count before Phase 1 (enables % progress; Linux uses GNU find -printf '\n' | wc -l when available). -# If false, Phase 1 shows file counts without a percentage. -precount_files_for_progress = false -# If true, follow symbolic links during directory walks (indexing only; shell precount unchanged). -follow_symlinks = false -# If true, hidden files and directories will be indexed. -include_hidden = false -# FTS5 tokenization method (e.g., 'trigram', 'porter', 'unicode61') -# Look here for more information https://www.sqlite.org/fts5.html#tokenizers -tokenize = "trigram" -# If true (default), extracted text is stored zstd-compressed in a sidecar -# table so the GUI's search results can render snippet previews with the -# query terms highlighted. If false, the inverted FTS5 index is still -# populated (so queries match the same files) but nothing is stored -# alongside; result rows carry no snippet. Turning this off drops the -# on-disk footprint to roughly what stock Baloo uses, useful for -# apples-to-apples comparisons and for users who never read snippet text. -# Changing this only affects files indexed *after* the change; existing -# sidecar rows are kept until re-indexed. -store_text_for_snippets = true \ No newline at end of file +# QuickSearch configuration reference. +# +# The live config is auto-created at ~/.config/quicksearch/config.toml +# (Windows: %APPDATA%\quicksearch\config.toml). A config.toml placed next +# to the quicksearch binary overrides it entirely (portable mode). +# Relative paths resolve against the directory containing the config +# file, so a portable folder can be moved wholesale. +# +# Every key is optional; missing keys take the defaults shown here. + +[paths] +# One or more directory roots to index. Walked in order; duplicate and +# nested roots are de-duplicated automatically. `~` expands to home. +indexing_paths = ["~"] +# SQLite index location. Default: ~/.local/share/quicksearch/index.sqlite +# On Windows the default is %LOCALAPPDATA%\quicksearch\index.sqlite. Write +# Windows paths as TOML *literal* strings (single quotes) so the +# backslashes need no escaping, and keep the index out of a roaming +# profile — it is far too large to synchronise: +# database_path = 'C:\Users\you\AppData\Local\quicksearch\index.sqlite' +database_path = "~/.local/share/quicksearch/index.sqlite" + +[indexing] +# Automatic mode: filesystem watchers apply changes as they happen and a +# full reindex runs every reindex_interval_minutes. The watcher catches +# changes as they happen, so this only needs to be often enough to cover +# whatever the watcher missed. +auto_index = true +reindex_interval_minutes = 1440 +# Follow symbolic links during directory walks. +follow_symlinks = false +# Index hidden files and directories. That means dot-files everywhere, and +# additionally anything carrying the Hidden or System attribute on Windows +# (AppData, $RECYCLE.BIN, System Volume Information, pagefile.sys ...). +include_hidden = false +# Empty = extract text from every supported format. Non-empty = content +# indexing only for these extensions; other files are still listed for +# filename search. Entries are case-insensitive, leading dot optional. +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. +# +# The Windows defaults add: "$RECYCLE.BIN", "System Volume Information", +# "pagefile.sys", "hiberfil.sys", "swapfile.sys", "Thumbs.db", +# "desktop.ini". +ignore_patterns = [".git", "node_modules", "*.tmp", ".venv", "venv"] + +# Worth adding by hand if you index a whole Windows drive rather than just +# your profile. Neither is excluded by default, because the default root +# is your profile and a bare "Windows" pattern would also match a folder +# of your own with that name: +# 'C:\Windows' — system files, nothing you would search for +# 'C:\Windows\WinSxS' — a hardlink farm that floods the Duplicates tab + +# Walker threads per root, keyed by the exact root string from +# indexing_paths. Absent or 0 = auto (4 on local storage, 16 on network +# mounts, detected per root). Applies at the start of the next run. +# root_workers = { "/media/share" = 24 } + +[processing] +# Bytes read from the start of each file for its content hash, which is +# `sha256(size || first hash_length bytes)` and backs duplicate detection. +# Only the head is read: seeking to the end for a second block costs an +# extra round trip per file on network shares. +# +# Known limitation: files of identical size whose heads match will be +# reported as duplicates. In practice that means pre-allocated VM disk +# images: a fixed-size VHD stores its unique footer at the end of the +# file, and a freshly pre-allocated raw/qcow2/VMDK image is all zeros at +# the head until it is partitioned. +hash_length = 8192 +# Maximum extracted text stored per file (bytes). +maximum_text_size = 262144 +# Files larger than this skip text extraction entirely (bytes). +maximum_text_file_size = 2097152 +# Files per batch during walks / inserts / extraction. +batch_size = 200 +# Files per transaction for incremental FTS updates. +fts_update_batch_size = 1000 +# FTS5 tokenizer: 'trigram' (substring matching, the default; gets +# remove_diacritics 1 appended), 'unicode61', 'porter', or a full FTS5 +# option string. See https://www.sqlite.org/fts5.html#tokenizers +tokenize = "trigram" +# Store extracted text (zstd-compressed) alongside the FTS index. Off: +# the index shrinks to roughly stock-Baloo size, but search loses snippet +# previews, occurrence ranking, case verification, and fuzzy full-text. +store_text_for_snippets = true + +[ui] +# Zoom factor for the whole GUI: fonts, spacing, and widgets scale +# together (0.5 – 2.5). Ctrl +/- and Ctrl 0 adjust it temporarily at +# runtime; this value is the persistent baseline. +scale = 1.1 + +[search] +# Start with the fuzzy passes enabled. +fuzzy_default = false +# Ceiling on the fuzzy stages' typo budget. The allowance grows with the +# search term, one edit per three characters, up to this value, so 2 +# means "1 edit for 3-5 character terms, 2 for anything longer". 0 turns +# the fuzzy stages off. Above 3 is allowed but not recommended: matches +# become dominated by coincidence and every fuzzy pass slows down. +fuzzy_max_edits = 2 +# Hard cap on results per search (the GUI's scroll list length). +display_limit = 1000 +# Results per streamed batch (latency/overhead knob, not a page size). +results_per_page = 100 +# How long the GUI waits after the last keystroke before searching (ms). +debounce_ms = 150 diff --git a/crates/quicksearch-core/Cargo.toml b/crates/quicksearch-core/Cargo.toml index f3b0ee2..3d93333 100644 --- a/crates/quicksearch-core/Cargo.toml +++ b/crates/quicksearch-core/Cargo.toml @@ -2,6 +2,10 @@ name = "quicksearch-core" version.workspace = true edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Indexing, storage and search engine behind QuickSearch." [lib] name = "quicksearch_core" @@ -17,10 +21,25 @@ serde = { version = "1.0", features = ["derive"] } toml = "0.8" mime_guess = "2.0" infer = "0.15" -pdf-extract = "0.7" +pdf-extract = "0.12" lopdf = "0.32" lofty = "0.19" kamadak-exif = "0.5" notify = "6.1" ctrlc = "3.4" zstd = "0.13" +globset = "0.4" +regex = "1" + +# `GetDriveTypeW` (a mapped drive letter is the only way to spot an SMB share +# that isn't written as UNC) plus the FILE_ATTRIBUTE_* constants for hidden +# detection. Pinned to 0.52 deliberately: walkdir → winapi-util already +# resolves exactly that version, so this adds no new crate compilations. +[target.'cfg(windows)'.dependencies] +# GetDriveTypeW and the FILE_ATTRIBUTE_* constants live in +# Win32_Storage_FileSystem; DRIVE_REMOTE, oddly, is filed under +# Win32_System_WindowsProgramming. +windows-sys = { version = "0.52", features = [ + "Win32_Storage_FileSystem", + "Win32_System_WindowsProgramming", +] } diff --git a/crates/quicksearch-core/examples/indexprobe.rs b/crates/quicksearch-core/examples/indexprobe.rs new file mode 100644 index 0000000..0661e5a --- /dev/null +++ b/crates/quicksearch-core/examples/indexprobe.rs @@ -0,0 +1,216 @@ +//! End-to-end timing and syscall accounting for a full indexing run. +//! +//! [`walkprobe`](walkprobe.rs) covers phase 1 alone, without a database. This +//! covers the whole pipeline — parallel walk, `files` writes, and content +//! extraction — because the interesting redundancy lives *between* the two +//! phases: the walk reads a file's head to hash it and sniff its MIME, and +//! extraction then reopens the same file and reads it again. +//! +//! ```text +//! cargo build -p quicksearch-core --example indexprobe --release +//! ./target/release/examples/indexprobe gen /tmp/qs-bench +//! ./target/release/examples/indexprobe cold /tmp/qs-bench /tmp/qs-bench.db +//! ./target/release/examples/indexprobe warm /tmp/qs-bench /tmp/qs-bench.db +//! ``` +//! +//! `cold` deletes the database first, so every file is new: the walk hashes +//! it and extraction reads it. `warm` re-runs over the existing database with +//! the tree untouched, which is the case that has to stay at one `stat` per +//! file — see [`crate::file_handling::classify_for_indexing`]. +//! +//! For syscalls per file, trace a run and bucket by the tree's paths: +//! +//! ```text +//! strace -f -y -o /tmp/t.log \ +//! -e trace=openat,statx,newfstatat,fstat,read,pread64,readlink,close,getdents64,lseek \ +//! ./target/release/examples/indexprobe cold /tmp/qs-bench /tmp/qs-bench.db +//! grep -oP '^\d+ \K[a-z0-9_]+' <(grep '/tmp/qs-bench/' /tmp/t.log) | sort | uniq -c +//! ``` +//! +//! Group by thread id instead (`grep -oP '^\d+ [a-z0-9_]+'`) to see the split +//! between the walk workers and the extraction thread. +//! +//! The run modes deliberately do no filesystem inspection of their own — no +//! progress walk, no size survey — so that every syscall the trace attributes +//! to the tree came from the indexer. The size histogram is printed by `gen`. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use quicksearch_core::config::Config; +use quicksearch_core::indexing::{IndexingService, IndexingStatus}; + +/// Files whose head the walk reads in full at the default 8 KiB +/// `hash_length`, i.e. the ones extraction never needs to reopen. +const SMALL_TEXT: usize = 800; +/// Text files past `hash_length`, which extraction must still read. +const LARGE_TEXT: usize = 100; +/// No extractor claims these, so extraction resolves them without touching +/// the disk. A control group: their cost must not move. +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", +]; + +/// Deterministic so two runs index byte-identical trees and their timings are +/// comparable. Plain LCG — this only has to spread, not to be random. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } + + fn in_range(&mut self, lo: usize, hi: usize) -> usize { + lo + (self.next() as usize) % (hi - lo) + } +} + +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]")); + + match mode.as_str() { + "gen" => generate(&tree), + "cold" | "warm" => { + 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)); + } + } + run(&mode, &tree, &db); + } + _ => { + eprintln!("usage: indexprobe [db]"); + std::process::exit(2); + } + } +} + +/// Build a tree with a size mix that separates the three code paths, and +/// report it so results are self-describing. +fn generate(tree: &Path) { + let _ = std::fs::remove_dir_all(tree); + std::fs::create_dir_all(tree).expect("create tree"); + + let mut rng = Rng(0x5eed); + let (mut small_bytes, mut large_bytes, mut bin_bytes) = (0usize, 0usize, 0usize); + + // Spread across subdirectories so the walk does real directory work + // rather than one enormous readdir. + for i in 0..SMALL_TEXT { + let dir = tree.join(format!("src/mod{}", i % 40)); + std::fs::create_dir_all(&dir).expect("mkdir"); + let ext = ["txt", "md", "rs", "json"][i % 4]; + let size = rng.in_range(200, 8 * 1024); + let body = prose(&mut rng, size); + small_bytes += body.len(); + std::fs::write(dir.join(format!("f{}.{}", i, ext)), body).expect("write"); + } + + for i in 0..LARGE_TEXT { + let dir = tree.join(format!("docs/set{}", i % 10)); + std::fs::create_dir_all(&dir).expect("mkdir"); + let size = rng.in_range(8 * 1024 + 1, 200 * 1024); + let body = prose(&mut rng, size); + large_bytes += body.len(); + std::fs::write(dir.join(format!("doc{}.md", i)), body).expect("write"); + } + + for i in 0..BINARY { + let dir = tree.join(format!("assets/set{}", i % 10)); + std::fs::create_dir_all(&dir).expect("mkdir"); + let n = rng.in_range(1024, 50 * 1024); + let blob: Vec = (0..n).map(|_| (rng.next() & 0xff) as u8).collect(); + bin_bytes += blob.len(); + std::fs::write(dir.join(format!("blob{}.bin", i)), blob).expect("write"); + } + + let total = SMALL_TEXT + LARGE_TEXT + BINARY; + eprintln!("generated {} files under {}", total, tree.display()); + eprintln!( + " text <= 8 KiB : {:5} files, {:8.1} MiB (head covers the whole file)", + SMALL_TEXT, + small_bytes as f64 / (1024.0 * 1024.0) + ); + eprintln!( + " text > 8 KiB : {:5} files, {:8.1} MiB (extraction must read it)", + LARGE_TEXT, + large_bytes as f64 / (1024.0 * 1024.0) + ); + eprintln!( + " binary : {:5} files, {:8.1} MiB (no extractor; control group)", + BINARY, + bin_bytes as f64 / (1024.0 * 1024.0) + ); +} + +fn prose(rng: &mut Rng, target: usize) -> String { + let mut s = String::with_capacity(target + 16); + while s.len() < target { + s.push_str(WORDS[rng.next() as usize % WORDS.len()]); + s.push(if rng.next() % 12 == 0 { '\n' } else { ' ' }); + } + s.truncate(target); + s +} + +fn run(mode: &str, tree: &Path, db: &Path) { + let config = Config::default(); + + // `run_indexing` writes this marker only on a successful finish, so it is + // the one unambiguous completion signal — polling the status enum races, + // because a small tree finishes between two polls and `Idle` then means + // both "not started" and "already done". + if db.exists() { + let conn = rusqlite::Connection::open(db).expect("open db"); + conn.execute("DELETE FROM schema_info WHERE key = 'last_full_index'", []) + .expect("clear marker"); + } + + let service = IndexingService::new(); + let start = Instant::now(); + service + .start_indexing( + vec![tree.to_string_lossy().into_owned()], + db.to_string_lossy().into_owned(), + config, + ) + .expect("start indexing"); + + let deadline = Instant::now() + Duration::from_secs(600); + let mut done = false; + while Instant::now() < deadline { + if let IndexingStatus::Error(e) = service.get_status() { + panic!("indexing failed: {}", e); + } + if db.exists() { + if let Ok(conn) = rusqlite::Connection::open(db) { + if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { + done = true; + break; + } + } + } + std::thread::sleep(Duration::from_millis(5)); + } + let elapsed = start.elapsed(); + assert!(done, "indexing did not finish within the timeout"); + service.stop_indexing().expect("stop"); + + let total = SMALL_TEXT + LARGE_TEXT + BINARY; + eprintln!( + "{}: {:?} ({:.0} files/sec over {} files)", + mode, + elapsed, + total as f64 / elapsed.as_secs_f64(), + total + ); +} diff --git a/crates/quicksearch-core/examples/walkprobe.rs b/crates/quicksearch-core/examples/walkprobe.rs new file mode 100644 index 0000000..8a517b1 --- /dev/null +++ b/crates/quicksearch-core/examples/walkprobe.rs @@ -0,0 +1,126 @@ +//! Times and syscall-counts the phase-1 walk, without touching a database. +//! +//! ```text +//! cargo build -p quicksearch-core --example walkprobe --release +//! ./target/release/examples/walkprobe parallel # the threaded walker +//! ./target/release/examples/walkprobe serial # one thread, for comparison +//! ``` +//! +//! Indexing a network share is bound by round trips, not bandwidth: every +//! metadata operation that misses the client cache costs one, and throughput +//! is round-trips-in-flight divided by latency. So the number that matters is +//! syscalls per file, which this makes directly visible: +//! +//! ```text +//! strace -f -c -e trace=openat,statx,newfstatat,readlink,getdents64,read,lseek,close \ +//! ./target/release/examples/walkprobe parallel +//! ``` +//! +//! Expect roughly one `statx` per unchanged file, plus open/read/close for +//! files that are new or modified, and `readlink` only for resolving the roots +//! themselves — a per-file `readlink` count means a `canonicalize` has crept +//! back into the hot path. +//! +//! Those four syscalls also now cover the *whole* cost of a small text file: +//! the head read for the hash is the file's entire contents, so the walk +//! extracts its text there and the content pass never opens it again. That +//! work is CPU, not syscalls, so it shows up in files/sec here and not in the +//! trace. Use [`indexprobe`](indexprobe.rs) to see both phases together. +//! +//! Both modes report files/sec. Run each twice: the first pass warms the page +//! cache (or, on a share, the client's attribute cache), so the second is the +//! one to compare. +use std::collections::HashMap; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, Mutex}; +use std::time::{Instant, UNIX_EPOCH}; + +use quicksearch_core::config::{Config, IgnoreSet}; +use quicksearch_core::extract::Registry; +use quicksearch_core::file_handling::{ + classify_for_indexing, filtered_walk, prepare_file_record, ExistingFileEntry, FileIndexAction, + UnreadableDirs, +}; +use quicksearch_core::walk::walk_indexable_files; + +fn main() { + let root = std::env::args().nth(1).unwrap(); + let mode = std::env::args().nth(2).unwrap_or_else(|| "parallel".into()); + let config = Config::default(); + let existing: HashMap = HashMap::new(); + + let start = Instant::now(); + let (seen, prepared) = match mode.as_str() { + "serial" => serial(&root, &config, &existing), + _ => parallel(&root, &config, existing), + }; + let elapsed = start.elapsed(); + + eprintln!( + "{mode}: {seen} files, {prepared} prepared in {:?} ({:.0} files/sec)", + elapsed, + seen as f64 / elapsed.as_secs_f64() + ); +} + +fn serial( + root: &str, + config: &Config, + existing: &HashMap, +) -> (usize, usize) { + let ignore = IgnoreSet::compile(&[]).unwrap(); + let registry = Registry::default_set(); + let (mut seen, mut prepared) = (0, 0); + for entry in filtered_walk(root, false, false, &ignore, &UnreadableDirs::default()) { + seen += 1; + // Same rule as the real walk: a name that is not valid UTF-8 cannot be + // stored in `files.path` and reopened by it, so it is skipped before + // anything tries to hash it. Counted as seen, never prepared. + let Some(path) = entry.path().to_str().map(str::to_owned) else { + continue; + }; + let Ok(meta) = std::fs::metadata(entry.path()) else { + continue; + }; + let Some(mtime) = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + else { + continue; + }; + if classify_for_indexing(&path, mtime, existing) != FileIndexAction::Skip + && prepare_file_record(&path, &meta, config, ®istry).is_some() + { + prepared += 1; + } + } + (seen, prepared) +} + +fn parallel( + root: &str, + config: &Config, + existing: HashMap, +) -> (usize, usize) { + let (mut seen, mut prepared) = (0, 0); + for file in walk_indexable_files( + &[root.to_string()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(existing), + config.clone(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ) { + seen += 1; + if file.record.is_some() { + prepared += 1; + } + } + (seen, prepared) +} diff --git a/crates/quicksearch-core/src/cli.rs b/crates/quicksearch-core/src/cli.rs index 512b729..191787f 100644 --- a/crates/quicksearch-core/src/cli.rs +++ b/crates/quicksearch-core/src/cli.rs @@ -8,7 +8,7 @@ use rusqlite::{params, OptionalExtension}; -use crate::db::open_or_recreate; +use crate::db::open_existing; use crate::db::repo::{STATE_DONE, STATE_FAILED, STATE_NA, STATE_PENDING}; /// Per-file indexing status, mirroring Baloo's multi-state reporting. @@ -86,7 +86,7 @@ impl SizeReport { /// Query the per-file indexing status. Returns `FileStatus` with /// `basic == NotIndexed` if the path isn't in the database. pub fn status_for_path(db_path: &str, path: &str) -> Result { - let conn = open_or_recreate(db_path, "trigram")?; + let conn = open_existing(db_path, false)?; let row: Option<(i64, i64, Option)> = conn .query_row( "SELECT basic_state, content_state, failure_msg FROM files WHERE path = ?1", @@ -113,7 +113,7 @@ pub fn status_for_path(db_path: &str, path: &str) -> Result /// Return every file that failed content extraction, newest first. pub fn list_failed(db_path: &str, limit: Option) -> Result, String> { - let conn = open_or_recreate(db_path, "trigram")?; + let conn = open_existing(db_path, false)?; let limit_sql = match limit { Some(n) => format!(" LIMIT {}", n), None => String::new(), @@ -147,7 +147,7 @@ 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 conn = open_or_recreate(db_path, "trigram")?; + let conn = open_existing(db_path, false)?; let count = |table: &str| -> Result { conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |r| r.get(0)) .map_err(|e| format!("count {}: {}", table, e)) @@ -181,7 +181,7 @@ pub fn index_size_breakdown(db_path: &str) -> Result { /// Used by the Baloo compat daemon to report the "Files waiting for content /// indexing" figure both to balooctl and to the LMDB mirror. pub fn pending_content_count(db_path: &str) -> Result { - let conn = open_or_recreate(db_path, "trigram")?; + let conn = open_existing(db_path, false)?; conn.query_row( "SELECT COUNT(*) FROM files WHERE content_state = ?1", rusqlite::params![crate::db::repo::STATE_PENDING], @@ -190,10 +190,33 @@ pub fn pending_content_count(db_path: &str) -> Result { .map_err(|e| format!("pending_content_count: {}", e)) } +/// Cheap aggregate counts for the GUI's idle status bar ("N files +/// indexed"). Callers cache the result; it's three COUNT scans, not +/// something to run per frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndexCounts { + pub files: i64, + pub content_done: i64, + pub content_pending: i64, +} + +pub fn index_counts(db_path: &str) -> Result { + let conn = open_existing(db_path, false)?; + let count = |sql: &str| -> Result { + conn.query_row(sql, [], |r| r.get(0)) + .map_err(|e| format!("index_counts: {}", e)) + }; + Ok(IndexCounts { + files: count("SELECT COUNT(*) FROM files")?, + content_done: count("SELECT COUNT(*) FROM files WHERE content_state = 1")?, + content_pending: count("SELECT COUNT(*) FROM files WHERE content_state = 0")?, + }) +} + /// Remove a single file from the index. Returns whether a row was deleted. /// Keeps FTS/documents/properties in sync via the repo helpers. pub fn clear_path(db_path: &str, path: &str) -> Result { - let mut conn = open_or_recreate(db_path, "trigram")?; + let mut conn = open_existing(db_path, true)?; let tx = conn .transaction() .map_err(|e| format!("clear_path begin tx: {}", e))?; @@ -206,6 +229,7 @@ pub fn clear_path(db_path: &str, path: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use crate::db::open_or_recreate; use crate::db::repo::{insert_file, set_content_done, set_content_failed, NewFile}; use crate::mime::FileType; @@ -374,4 +398,51 @@ mod tests { std::fs::remove_file(&p).ok(); } + + #[test] + fn clear_path_on_nondefault_tokenizer_db_removes_only_target() { + // Regression: clear_path used to open with a hardcoded "trigram", so on + // an index built with a non-default tokenizer the schema-mismatch wipe + // destroyed the WHOLE index instead of deleting one row. With + // open_existing it must delete only the target and leave the rest. + let p = tmp_path(); + let dbp = p.to_str().unwrap(); + { + let mut conn = open_or_recreate(dbp, "unicode61").unwrap(); + let tx = conn.transaction().unwrap(); + for (name, path) in [("a.txt", "/tmp/a.txt"), ("b.txt", "/tmp/b.txt")] { + insert_file( + &tx, + &NewFile { + name, + path, + parent: "/tmp", + size: 1, + mtime: 1, + inode: None, + device_id: None, + mime: Some("text/plain"), + ftype: FileType::TEXT, + hash: None, + }, + ) + .unwrap() + .expect("unique path"); + } + tx.commit().unwrap(); + } + + assert!(clear_path(dbp, "/tmp/a.txt").unwrap()); + // The other row must survive — proof we deleted one row, not wiped. + assert_eq!( + status_for_path(dbp, "/tmp/b.txt").unwrap().basic, + IndexState::Done + ); + assert_eq!( + status_for_path(dbp, "/tmp/a.txt").unwrap().basic, + IndexState::NotIndexed + ); + + std::fs::remove_file(&p).ok(); + } } diff --git a/crates/quicksearch-core/src/config.rs b/crates/quicksearch-core/src/config.rs index 35078e1..f88b567 100644 --- a/crates/quicksearch-core/src/config.rs +++ b/crates/quicksearch-core/src/config.rs @@ -1,115 +1,1058 @@ -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::Path; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct Config { - pub paths: PathConfig, - pub processing: ProcessingConfig, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct PathConfig { - /// One or more directory roots to index. Indexing walks each root - /// independently; duplicates and nested roots are de-duplicated by the - /// indexer at run time. Must contain at least one entry. - pub indexing_paths: Vec, - pub database_path: String, -} - -fn default_fts_update_batch_size() -> usize { - 1000 -} - -/// Platform-sensible default for the first indexing root when no config -/// exists. `$HOME` on Unix, `%USERPROFILE%` on Windows; falls back to the -/// current directory. -fn default_home_path() -> String { - if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) { - return home.to_string_lossy().into_owned(); - } - ".".to_string() -} - -fn default_store_text_for_snippets() -> bool { - true -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct ProcessingConfig { - pub hash_length: usize, - pub maximum_text_size: usize, - pub maximum_text_file_size: u64, - pub batch_size: usize, - #[serde(default = "default_fts_update_batch_size")] - pub fts_update_batch_size: usize, - pub tokenize: String, - #[serde(default)] - pub precount_files_for_progress: bool, - #[serde(default)] - pub follow_symlinks: bool, - #[serde(default)] - pub include_hidden: bool, - /// When `true` (default), extracted text is stored zstd-compressed in - /// `documents_text` so search results can render snippet/highlight - /// previews without re-reading the source file. When `false` the - /// inverted FTS5 index still gets the tokens (so queries return the - /// same hits) but nothing is stored alongside; search results carry - /// no snippet text and rely on filename/path only. This mode drops - /// the on-disk footprint to roughly what stock Baloo uses, at the - /// cost of snippet functionality — useful for apples-to-apples size - /// comparisons and for users who never look at result previews. - #[serde(default = "default_store_text_for_snippets")] - pub store_text_for_snippets: bool, -} - -impl Default for Config { - fn default() -> Self { - Config { - paths: PathConfig { - indexing_paths: vec![default_home_path()], - database_path: "QuickSearch.db".to_string(), - }, - processing: ProcessingConfig { - hash_length: 1024 * 8, - maximum_text_size: 1024 * 256, - maximum_text_file_size: 1024 * 1024 * 2, - batch_size: 200, - fts_update_batch_size: 1000, - tokenize: "trigram".to_string(), - precount_files_for_progress: false, - follow_symlinks: false, - include_hidden: false, - store_text_for_snippets: true, - }, - } - } -} - -impl Config { - pub fn load() -> Result { - let config_path = "config.toml"; - - if Path::new(config_path).exists() { - let content = fs::read_to_string(config_path) - .map_err(|e| format!("Failed to read config file: {}", e))?; - - toml::from_str(&content) - .map_err(|e| format!("Failed to parse config file: {}", e)) - } else { - let default_config = Config::default(); - default_config.save()?; - Ok(default_config) - } - } - - pub fn save(&self) -> Result<(), String> { - let content = toml::to_string_pretty(self) - .map_err(|e| format!("Failed to serialize config: {}", e))?; - - fs::write("config.toml", content) - .map_err(|e| format!("Failed to write config file: {}", e))?; - - Ok(()) - } -} +//! Configuration: TOML file, resolution rules, filter sets, and live-update +//! classification. +//! +//! File resolution (see [`Config::config_path`]): a `config.toml` sitting +//! next to the executable wins ("portable mode"); otherwise the per-user +//! XDG location is used and auto-created on first run. Relative paths +//! *inside* a config resolve against the config file's own directory, so a +//! portable folder can be moved wholesale. +//! +//! The GUI is the only writer of the file at runtime; external edits take +//! effect on next start. After editing, callers run [`diff_actions`] to +//! learn which running services must react (rebuild the index, restart the +//! watcher, repoint search). + +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Config { + pub paths: PathConfig, + pub indexing: IndexingConfig, + pub processing: ProcessingConfig, + pub search: SearchConfig, + pub ui: UiConfig, + /// File this config was loaded from; `save()` writes back to it. + /// `None` for hand-built configs (tests), which save to the default + /// location. + #[serde(skip)] + pub source: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct PathConfig { + /// One or more directory roots to index. Indexing walks each root + /// independently; duplicates and nested roots are de-duplicated by the + /// indexer at run time. + pub indexing_paths: Vec, + /// SQLite index location. Relative values resolve against the config + /// file's directory; `~` expands to the home directory. + pub database_path: String, +} + +/// What to index and when — the knobs the coordinator and walker consume. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct IndexingConfig { + /// Start in automatic mode: filesystem watchers apply changes as they + /// happen and a full reindex runs every `reindex_interval_minutes`. + pub auto_index: bool, + pub reindex_interval_minutes: u64, + pub follow_symlinks: bool, + pub include_hidden: bool, + /// Empty = extract content from everything the extractor registry + /// supports. Non-empty = only files with these extensions get content + /// extraction/FTS; everything else is still listed for filename search + /// (`content_state = NA`). Entries are case-insensitive, with or + /// without a leading dot. + pub content_extensions: Vec, + /// Excluded from the index entirely — never even listed. A pattern + /// without `/` matches any single path component (so `.git` prunes + /// whole subtrees); a pattern containing `/` or resembling a path is + /// matched against the full path. Glob syntax (`*`, `?`, `[..]`). + pub ignore_patterns: Vec, + /// Per-root walker thread override, keyed by the root string exactly + /// as it appears in `indexing_paths`. Absent or 0 = auto-detect + /// (4 for local storage, 16 for network mounts). Read at run start; + /// a change applies to the next run. + pub root_workers: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct ProcessingConfig { + /// Bytes read from the head of each new or changed file. Those bytes do + /// three jobs, so this one number sets more than the hash: + /// + /// 1. with the size, they identify the file (see `get_file_hash`); + /// 2. they are the magic-byte window for MIME detection — `infer` reads + /// 8 KiB from a path and its longest matcher needs 262 bytes, so the + /// default is exactly as good as opening the file, and a value under + /// 262 makes some formats undetectable except by extension; + /// 3. any plaintext file no larger than this is extracted during the + /// walk, sparing the content pass an open/read/close. + /// + /// Changing it invalidates stored hashes and forces a rebuild. + pub hash_length: usize, + pub maximum_text_size: usize, + pub maximum_text_file_size: u64, + pub batch_size: usize, + pub fts_update_batch_size: usize, + pub tokenize: String, + /// When `true` (default), extracted text is stored zstd-compressed in + /// `documents_text` so search results can render snippet/highlight + /// previews without re-reading the source file. When `false` the + /// inverted FTS5 index still gets the tokens (so queries return the + /// same hits) but nothing is stored alongside; full-text results carry + /// no snippets, can't be case-verified or occurrence-ranked, and fuzzy + /// full-text search is unavailable. This mode drops the on-disk + /// footprint to roughly what stock Baloo uses. + pub store_text_for_snippets: bool, +} + +/// Fuzzy edit distances above this are allowed but warned about: matches +/// become dominated by coincidence and every fuzzy pass slows down. +pub const FUZZY_EDITS_WARN_ABOVE: usize = 3; + +/// Search-side preferences, shared by the GUI and the CLI mode. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct SearchConfig { + /// Whether the fuzzy stages start enabled. + pub fuzzy_default: bool, + /// Ceiling on the fuzzy stages' Levenshtein budget. The budget grows + /// with the term (one edit per three characters) up to this cap, so 2 + /// means "1 edit for 3–5 character terms, 2 for anything longer". + /// 0 disables the fuzzy stages entirely; above + /// [`FUZZY_EDITS_WARN_ABOVE`] the GUI and CLI warn. + pub fuzzy_max_edits: usize, + /// Hard cap on buffered/displayed results per search. + pub display_limit: usize, + /// Streaming batch size — how many hits per update event. + pub results_per_page: usize, + /// How long the GUI waits after the last keystroke before searching. + pub debounce_ms: u64, +} + +impl SearchConfig { + /// The caution to show next to `fuzzy_max_edits`, or `None` when the + /// value is sane. One wording, shared by the Options window and the + /// terminal mode. + pub fn fuzzy_edits_warning(&self) -> Option { + if self.fuzzy_max_edits <= FUZZY_EDITS_WARN_ABOVE { + return None; + } + Some(format!( + "Fuzzy edit distance {} is above the recommended maximum of {}; \ + results will be dominated by false matches and every fuzzy pass \ + gets slower.", + self.fuzzy_max_edits, FUZZY_EDITS_WARN_ABOVE + )) + } +} + +impl Default for PathConfig { + fn default() -> Self { + PathConfig { + indexing_paths: vec![default_home_path()], + database_path: default_db_path().to_string_lossy().into_owned(), + } + } +} + +impl Default for IndexingConfig { + fn default() -> Self { + IndexingConfig { + auto_index: true, + reindex_interval_minutes: 24 * 60, + follow_symlinks: false, + include_hidden: false, + content_extensions: Vec::new(), + ignore_patterns: default_ignore_patterns(), + root_workers: std::collections::HashMap::new(), + } + } +} + +impl Default for ProcessingConfig { + fn default() -> Self { + ProcessingConfig { + hash_length: 1024 * 8, + maximum_text_size: 1024 * 256, + maximum_text_file_size: 1024 * 1024 * 2, + batch_size: 200, + fts_update_batch_size: 1000, + tokenize: "trigram".to_string(), + store_text_for_snippets: true, + } + } +} + +impl Default for SearchConfig { + fn default() -> Self { + SearchConfig { + fuzzy_default: false, + fuzzy_max_edits: 2, + display_limit: 1000, + results_per_page: 100, + debounce_ms: 150, + } + } +} + +/// Interface preferences. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct UiConfig { + /// Zoom factor for the whole GUI — fonts, spacing, and widgets scale + /// together. Applied live; also adjustable with Ctrl +/-/0 at runtime + /// (keyboard zoom isn't persisted). + pub scale: f32, + /// Roots the "live updates are disabled" warning has already been shown + /// for, as they appear in `indexing_paths`. + /// + /// Keyed by root rather than a single flag so that adding a folder warns + /// again — the trade-off changed — while restarting the app does not. + /// Pruned to the current root set whenever the folder list is applied. + pub watch_cap_warned_roots: Vec, +} + +impl Default for UiConfig { + fn default() -> Self { + UiConfig { + scale: 1.1, + watch_cap_warned_roots: Vec::new(), + } + } +} + +impl Default for Config { + fn default() -> Self { + Config { + paths: PathConfig::default(), + indexing: IndexingConfig::default(), + processing: ProcessingConfig::default(), + search: SearchConfig::default(), + ui: UiConfig::default(), + source: None, + } + } +} + +/// Directories and files excluded from a fresh index. +/// +/// Build artefacts everywhere, plus the things a Windows home directory or +/// drive root contains that are actively harmful to index: +/// +/// - `$RECYCLE.BIN` holds *deleted* files. Indexing it puts their contents +/// back into search results, which is a privacy problem rather than mere +/// noise. It is Hidden+System, so the walk already skips it by default — +/// this covers the user who legitimately turns `include_hidden` on. (`$` is +/// not a glob metacharacter, so the name is matched literally.) +/// - `System Volume Information` is ACL-denied even to Administrators, so +/// without it every run logs an unreadable-directory warning per drive. +/// - The kernel's paging and hibernation files are multi-gigabyte and +/// permanently locked. +/// - `Thumbs.db` and `desktop.ini` occur in thousands of folders and carry no +/// searchable content. +/// +/// Deliberately absent: any exclusion for `C:\Windows`. The default root is +/// the user's profile, so it would never apply, and a pattern general enough +/// to catch it would also catch a user folder named `Windows`. +/// `config_example.toml` documents it for people who add a drive root. +fn default_ignore_patterns() -> Vec { + let mut patterns = vec![".git", "node_modules", "*.tmp", ".venv", "venv"]; + if cfg!(windows) { + patterns.extend([ + "$RECYCLE.BIN", + "System Volume Information", + "pagefile.sys", + "hiberfil.sys", + "swapfile.sys", + "Thumbs.db", + "desktop.ini", + ]); + } + patterns.into_iter().map(str::to_string).collect() +} + +/// Platform-sensible default for the first indexing root when no config +/// exists. `$HOME` on Unix, `%USERPROFILE%` on Windows; falls back to the +/// current directory. +fn default_home_path() -> String { + if let Some(home) = crate::platform::home_dir() { + return home.to_string_lossy().into_owned(); + } + ".".to_string() +} + +/// `config.toml` beside the running executable, if the executable's +/// location is known. Existence is checked by the caller. +fn portable_config_path() -> Option { + let exe = std::env::current_exe().ok()?; + Some(exe.parent()?.join("config.toml")) +} + +/// Per-user config directory: `$XDG_CONFIG_HOME`/`~/.config` on Unix, +/// `%APPDATA%` on Windows. +fn config_base_dir() -> PathBuf { + #[cfg(windows)] + { + if let Some(d) = std::env::var_os("APPDATA") { + return PathBuf::from(d); + } + } + if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") { + let p = PathBuf::from(d); + if p.is_absolute() { + return p; + } + } + if let Some(home) = crate::platform::home_dir() { + return PathBuf::from(home).join(".config"); + } + PathBuf::from(".") +} + +/// Per-user data directory: `$XDG_DATA_HOME`/`~/.local/share` on Unix, +/// `%LOCALAPPDATA%` on Windows. +fn data_base_dir() -> PathBuf { + #[cfg(windows)] + { + if let Some(d) = std::env::var_os("LOCALAPPDATA") { + return PathBuf::from(d); + } + } + if let Some(d) = std::env::var_os("XDG_DATA_HOME") { + let p = PathBuf::from(d); + if p.is_absolute() { + return p; + } + } + if let Some(home) = crate::platform::home_dir() { + return PathBuf::from(home).join(".local").join("share"); + } + PathBuf::from(".") +} + +/// Default index location when the config doesn't name one. +pub fn default_db_path() -> PathBuf { + data_base_dir().join("quicksearch").join("index.sqlite") +} + +/// Expand a leading `~`/`~/` to the home directory. Other `~user` forms are +/// left untouched. +fn expand_tilde(path: &str) -> PathBuf { + if path == "~" || path.starts_with("~/") || path.starts_with("~\\") { + if let Some(home) = crate::platform::home_dir() { + let mut p = PathBuf::from(home); + if path.len() > 2 { + p.push(&path[2..]); + } + return p; + } + } + PathBuf::from(path) +} + +impl Config { + /// The config file this process should use: the portable override next + /// to the binary when present, else the XDG location. + pub fn config_path() -> PathBuf { + if let Some(p) = portable_config_path() { + if p.exists() { + return p; + } + } + config_base_dir().join("quicksearch").join("config.toml") + } + + pub fn load() -> Result { + Self::load_from(&Self::config_path()) + } + + /// Load from an explicit path. A missing file is created with defaults + /// (directories included). The loaded config remembers `path` and + /// `save()` writes back to it. + pub fn load_from(path: &Path) -> Result { + if path.exists() { + let content = fs::read_to_string(path) + .map_err(|e| format!("Failed to read config file {}: {}", path.display(), e))?; + let mut cfg: Config = toml::from_str(&content) + .map_err(|e| format!("Failed to parse config file {}: {}", path.display(), e))?; + cfg.source = Some(path.to_path_buf()); + Ok(cfg) + } else { + let mut cfg = Config::default(); + cfg.source = Some(path.to_path_buf()); + cfg.save()?; + Ok(cfg) + } + } + + /// Write back to the file this config was loaded from (or the default + /// 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); + if let Some(dir) = path.parent() { + fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create config dir {}: {}", dir.display(), e))?; + } + let content = toml::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + fs::write(&path, content) + .map_err(|e| format!("Failed to write config file {}: {}", path.display(), e))?; + Ok(()) + } + + /// Directory that relative in-config paths resolve against: the config + /// file's own directory, falling back to the CWD for sourceless configs. + fn base_dir(&self) -> PathBuf { + self.source + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")) + } + + /// `database_path` with `~` expanded and relative values resolved + /// against the config file's directory. + pub fn resolved_database_path(&self) -> PathBuf { + let p = expand_tilde(&self.paths.database_path); + if p.is_absolute() { + p + } else { + self.base_dir().join(p) + } + } + + /// `indexing_paths` with the same resolution rules as + /// [`resolved_database_path`]. + pub fn resolved_indexing_paths(&self) -> Vec { + self.paths + .indexing_paths + .iter() + .map(|raw| { + let p = expand_tilde(raw); + if p.is_absolute() { + p + } else { + self.base_dir().join(p) + } + }) + .collect() + } +} + +/// Whether a file's content (text extraction + FTS) should be indexed under +/// the `content_extensions` filter. Files that fail this are still listed +/// for filename search. Empty filter = everything allowed. +pub fn content_allowed(path: &Path, cfg: &Config) -> bool { + if cfg.indexing.content_extensions.is_empty() { + return true; + } + let ext = match path.extension().and_then(|e| e.to_str()) { + Some(e) => e.to_ascii_lowercase(), + None => return false, + }; + cfg.indexing + .content_extensions + .iter() + .any(|allowed| allowed.trim_start_matches('.').eq_ignore_ascii_case(&ext)) +} + +/// Compiled ignore patterns, split by matching scope: patterns without a +/// path separator match any single path component; the rest match the full +/// path. Both use glob syntax. +#[derive(Debug)] +pub struct IgnoreSet { + component: globset::GlobSet, + path: globset::GlobSet, + empty: bool, +} + +impl IgnoreSet { + pub fn compile(patterns: &[String]) -> Result { + let mut component = globset::GlobSetBuilder::new(); + 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(['/', '\\']); + if pat.is_empty() { + continue; + } + let glob = globset::GlobBuilder::new(pat) + .literal_separator(false) + // Match the filesystem's own rules, or `node_modules` fails to + // exclude `Node_Modules`. globset already handles the other + // half of Windows compatibility on its own: `Candidate` folds + // `\` to `/` when matching, and backslash-as-escape is off + // wherever `\` is a separator. + .case_insensitive(cfg!(any(windows, target_os = "macos"))) + .build() + .map_err(|e| format!("invalid ignore pattern {:?}: {}", pat, e))?; + if pat.contains('/') || pat.contains('\\') { + path.add(glob); + } else { + component.add(glob); + } + } + let component = component + .build() + .map_err(|e| format!("compile ignore patterns: {}", e))?; + let path = path + .build() + .map_err(|e| format!("compile ignore patterns: {}", e))?; + let empty = component.is_empty() && path.is_empty(); + Ok(IgnoreSet { + component, + path, + empty, + }) + } + + /// Match a single file/directory name. Used by the walker to prune + /// subtrees before descending. + pub fn matches_component(&self, name: &str) -> bool { + !self.empty && self.component.is_match(name) + } + + /// Match a path against the full-path patterns only. The path *and its + /// ancestors* are tested, so a pattern matching a directory ignores + /// everything beneath it — the same semantics the walker gets by + /// pruning that directory before descending. + pub fn matches_path_pattern(&self, path: &Path) -> bool { + if self.path.is_empty() { + return false; + } + let mut cur = Some(path); + while let Some(p) = cur { + if self.path.is_match(p) { + return true; + } + cur = p.parent(); + } + false + } + + /// Match a full path: either a full-path pattern hits it (or an + /// ancestor), or any single component matches a component pattern. + /// Used for watcher events, where walk-time pruning never saw the path. + pub fn matches_path(&self, path: &Path) -> bool { + if self.empty { + return false; + } + if self.matches_path_pattern(path) { + return true; + } + path.components().any(|c| { + matches!(c, std::path::Component::Normal(name) + if self.component.is_match(Path::new(name))) + }) + } + + pub fn is_empty(&self) -> bool { + self.empty + } +} + +/// What running services must do after a config edit. Computed by the GUI +/// (the only runtime editor) after saving. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConfigActions { + /// The stored index no longer matches how it would be built — offer the + /// user a rebuild (mirrors the `config_validation` mechanism). + pub requires_rebuild: bool, + /// Watched roots or link semantics changed — restart the watcher. + pub restart_watcher: bool, + /// Searches must reopen against a different database file. + pub search_db_changed: bool, +} + +/// Roots that live inside other roots, as `(child, parent)` pairs (exact +/// duplicates are reported once). Nested roots are disallowed: with one +/// walker per root they would race for the same files and split progress +/// attribution. Comparison is on best-effort canonicalized paths (an +/// unresolvable root is compared as spelled) and is component-boundary +/// aware — `/a/bc` is not under `/a/b`. +pub fn nested_roots(roots: &[String]) -> Vec<(String, String)> { + let resolved: Vec = roots + .iter() + .map(|r| { + let p = expand_tilde(r); + fs::canonicalize(&p).unwrap_or(p) + }) + .collect(); + let mut out = Vec::new(); + for (i, child) in resolved.iter().enumerate() { + for (j, parent) in resolved.iter().enumerate() { + if i == j { + continue; + } + if child == parent { + if i > j { + out.push((roots[i].clone(), roots[j].clone())); + } + } else if child.starts_with(parent) { + out.push((roots[i].clone(), roots[j].clone())); + } + } + } + out +} + +pub fn diff_actions(old: &Config, new: &Config) -> ConfigActions { + let roots_changed = old.paths.indexing_paths != new.paths.indexing_paths; + let requires_rebuild = old.processing.hash_length != new.processing.hash_length + || old.processing.tokenize != new.processing.tokenize + || old.indexing.include_hidden != new.indexing.include_hidden + || old.indexing.ignore_patterns != new.indexing.ignore_patterns + || old.indexing.content_extensions != new.indexing.content_extensions + || roots_changed; + ConfigActions { + requires_rebuild, + restart_watcher: requires_rebuild + || roots_changed + || old.indexing.follow_symlinks != new.indexing.follow_symlinks, + search_db_changed: old.paths.database_path != new.paths.database_path, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_dir() -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "quicksearch-config-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&p).unwrap(); + p + } + + #[test] + fn fresh_install_defaults_to_home_as_only_root() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + assert!(!path.exists(), "fresh install: no config yet"); + let cfg = Config::load_from(&path).unwrap(); + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .expect("test environment has a home dir"); + assert_eq!( + cfg.paths.indexing_paths, + vec![home], + "the user's home folder must be the only default index root" + ); + // The auto-created file round-trips identically. + let reloaded = Config::load_from(&path).unwrap(); + assert_eq!(reloaded.paths.indexing_paths, cfg.paths.indexing_paths); + // A [paths] section that omits indexing_paths also falls back to + // home, not to an empty list. + fs::write(&path, "[paths]\ndatabase_path = \"x.sqlite\"\n").unwrap(); + let partial = Config::load_from(&path).unwrap(); + assert_eq!(partial.paths.indexing_paths, cfg.paths.indexing_paths); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn missing_file_created_with_defaults() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + let cfg = Config::load_from(&path).unwrap(); + assert!(path.exists(), "default config file should be written"); + assert_eq!(cfg.search.display_limit, 1000); + assert_eq!(cfg.search.results_per_page, 100); + assert!(cfg.indexing.auto_index); + assert_eq!(cfg.source.as_deref(), Some(path.as_path())); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + 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(); + let cfg = Config::load_from(&path).unwrap(); + assert_eq!(cfg.paths.indexing_paths, vec!["/x".to_string()]); + assert_eq!(cfg.processing.batch_size, 200, "missing sections default"); + assert_eq!(cfg.search.debounce_ms, 150); + assert!((cfg.ui.scale - 1.1).abs() < f32::EPSILON); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn relative_paths_resolve_against_config_dir() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + fs::write( + &path, + "[paths]\nindexing_paths=[\"data\"]\ndatabase_path=\"index.sqlite\"\n", + ) + .unwrap(); + let cfg = Config::load_from(&path).unwrap(); + assert_eq!(cfg.resolved_database_path(), dir.join("index.sqlite")); + assert_eq!(cfg.resolved_indexing_paths(), vec![dir.join("data")]); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn save_keeps_relative_paths_portable() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + fs::write( + &path, + "[paths]\nindexing_paths=[\"data\"]\ndatabase_path=\"index.sqlite\"\n", + ) + .unwrap(); + let cfg = Config::load_from(&path).unwrap(); + cfg.save().unwrap(); + let text = fs::read_to_string(&path).unwrap(); + assert!( + text.contains("database_path = \"index.sqlite\""), + "relative path must survive a save round-trip: {}", + text + ); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn tilde_expansion() { + if std::env::var_os("HOME").is_none() { + return; // nothing to assert without a home dir + } + let mut cfg = Config::default(); + cfg.paths.database_path = "~/qs/index.sqlite".to_string(); + let resolved = cfg.resolved_database_path(); + assert!(resolved.is_absolute()); + assert!(!resolved.to_string_lossy().contains('~')); + } + + #[test] + fn content_allowed_semantics() { + let mut cfg = Config::default(); + assert!(content_allowed(Path::new("/a/b.xyz"), &cfg), "empty = all"); + 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/b.pdf"), &cfg)); + assert!(!content_allowed(Path::new("/a/noext"), &cfg)); + } + + #[test] + fn ignore_set_component_vs_path() { + let set = IgnoreSet::compile(&[ + ".git".to_string(), + "*.tmp".to_string(), + "/home/*/secret".to_string(), + "".to_string(), // blank lines ignored + ]) + .unwrap(); + assert!(set.matches_component(".git")); + assert!(set.matches_component("junk.tmp")); + assert!(!set.matches_component("git")); + // Full-path checks catch both kinds. + assert!(set.matches_path(Path::new("/repo/.git/config"))); + assert!(set.matches_path(Path::new("/x/y/file.tmp"))); + assert!(set.matches_path(Path::new("/home/bob/secret"))); + // A dir-matching path pattern ignores everything beneath it, same + // as the walker pruning that directory. + assert!(set.matches_path(Path::new("/home/bob/secret/inner/deep.txt"))); + assert!(!set.matches_path(Path::new("/home/bob/public"))); + assert!(!set.matches_path(Path::new("/repo/src/main.rs"))); + } + + #[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 + ]) + .unwrap(); + // The directory itself and everything beneath it. + assert!(set.matches_path(Path::new("/tmp"))); + assert!(set.matches_path(Path::new("/tmp/a/b/c.txt"))); + assert!(!set.matches_path(Path::new("/tmpfoo/file.txt"))); + // "cache/" behaves like the component pattern "cache". + assert!(set.matches_path(Path::new("/home/x/cache/obj.bin"))); + // Suffix form matches the dir at any depth. + assert!(set.matches_path(Path::new("/repo/sub/target/debug/app"))); + // A bare "/" must not ignore the universe. + assert!(!set.matches_path(Path::new("/etc/passwd"))); + } + + #[test] + fn ignore_set_invalid_pattern_errors() { + let err = IgnoreSet::compile(&["[".to_string()]).unwrap_err(); + assert!(err.contains("invalid ignore pattern"), "{}", err); + } + + #[test] + fn empty_ignore_set_matches_nothing() { + let set = IgnoreSet::compile(&[]).unwrap(); + assert!(set.is_empty()); + assert!(!set.matches_path(Path::new("/any/thing"))); + assert!(!set.matches_component("anything")); + } + + /// Pattern matching must follow the filesystem's own case rules, or + /// `node_modules` silently fails to exclude `Node_Modules` on Windows. + #[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"); + + let folded = cfg!(any(windows, target_os = "macos")); + assert_eq!( + set.matches_component("Node_Modules"), + folded, + "case folding must track the platform's filesystem semantics" + ); + } + + #[test] + fn default_ignore_patterns_cover_the_platform() { + let d = IndexingConfig::default().ignore_patterns; + for shared in [".git", "node_modules", "*.tmp", ".venv", "venv"] { + assert!(d.iter().any(|p| p == shared), "missing {}", shared); + } + + // `$RECYCLE.BIN` holds deleted files; indexing it would surface their + // contents in search results. + let recycle = d.iter().any(|p| p == "$RECYCLE.BIN"); + assert_eq!(recycle, cfg!(windows), "Windows-only exclusions"); + + // Whatever the platform, the defaults must actually compile — a + // pattern like `$RECYCLE.BIN` going through globset is the risk. + let set = IgnoreSet::compile(&d).expect("default patterns compile"); + assert!(!set.is_empty()); + if cfg!(windows) { + assert!( + set.matches_component("$RECYCLE.BIN"), + "`$` must be matched literally, not as a metacharacter" + ); + } + } + + #[test] + fn nested_roots_matrix() { + // Straight nesting (paths don't exist → compared as spelled). + assert_eq!( + nested_roots(&["/qs-x/b".into(), "/qs-x/b/c".into()]), + vec![("/qs-x/b/c".to_string(), "/qs-x/b".to_string())] + ); + // Component boundary: /a/bc is NOT under /a/b. + assert!(nested_roots(&["/qs-x/b".into(), "/qs-x/bc".into()]).is_empty()); + // Disjoint roots. + assert!(nested_roots(&["/qs-x/b".into(), "/qs-x/c".into()]).is_empty()); + // Exact duplicates flag once. + assert_eq!(nested_roots(&["/qs-x".into(), "/qs-x".into()]).len(), 1); + // Empty and singleton lists are fine. + assert!(nested_roots(&[]).is_empty()); + assert!(nested_roots(&["/qs-x".into()]).is_empty()); + // Symlinked spellings of the same real directory are caught via + // canonicalization. + #[cfg(unix)] + { + let dir = tmp_dir(); + let real = dir.join("real"); + fs::create_dir_all(&real).unwrap(); + let link = dir.join("alias"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + let pairs = nested_roots(&[ + real.to_string_lossy().into_owned(), + link.to_string_lossy().into_owned(), + ]); + assert_eq!(pairs.len(), 1, "alias of the same dir counts as duplicate"); + fs::remove_dir_all(&dir).ok(); + } + } + + #[test] + fn removed_precount_key_still_parses() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + fs::write( + &path, + "[processing]\nprecount_files_for_progress = true\nbatch_size = 42\n", + ) + .unwrap(); + let cfg = Config::load_from(&path).unwrap(); + assert_eq!(cfg.processing.batch_size, 42, "known keys still load"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn root_workers_round_trip() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + let mut cfg = Config::default(); + cfg.source = Some(path.clone()); + cfg.paths.indexing_paths = vec!["/data".into(), "/share".into()]; + cfg.indexing.root_workers.insert("/share".into(), 24); + 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"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn diff_actions_matrix() { + let base = Config::default(); + + let same = diff_actions(&base, &base.clone()); + assert_eq!( + same, + ConfigActions { + requires_rebuild: false, + restart_watcher: false, + search_db_changed: false + } + ); + + let mut c = base.clone(); + c.processing.tokenize = "unicode61".into(); + assert!(diff_actions(&base, &c).requires_rebuild); + + let mut c = base.clone(); + c.indexing.ignore_patterns.push("*.log".into()); + let a = diff_actions(&base, &c); + assert!(a.requires_rebuild && a.restart_watcher); + + let mut c = base.clone(); + c.indexing.follow_symlinks = true; + let a = diff_actions(&base, &c); + assert!(!a.requires_rebuild && a.restart_watcher); + + let mut c = base.clone(); + c.paths.database_path = "/elsewhere.sqlite".into(); + let a = diff_actions(&base, &c); + assert!(a.search_db_changed && !a.requires_rebuild); + + let mut c = base.clone(); + c.search.display_limit = 5000; + c.processing.batch_size = 999; + c.indexing.auto_index = false; + c.indexing.reindex_interval_minutes = 5; + let a = diff_actions(&base, &c); + assert_eq!( + a, + ConfigActions { + requires_rebuild: false, + restart_watcher: false, + search_db_changed: false + }, + "soft knobs never force restarts" + ); + } + + /// Dismissing the watch-cap warning must not trigger a rebuild or a + /// watcher restart — it is pure UI bookkeeping, and restarting the + /// watcher would re-trip the very warning being dismissed. + #[test] + fn watch_cap_warned_roots_is_a_soft_knob() { + let base = Config::default(); + let mut c = base.clone(); + c.ui.watch_cap_warned_roots = vec!["/media/ApolloStore".to_string()]; + let a = diff_actions(&base, &c); + assert_eq!( + a, + ConfigActions { + requires_rebuild: false, + restart_watcher: false, + search_db_changed: false + } + ); + } + + #[test] + fn watch_cap_warned_roots_round_trips() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + let mut cfg = Config::default(); + cfg.source = Some(path.clone()); + cfg.ui.watch_cap_warned_roots = + vec!["/media/ApolloStore".to_string(), "/media/GSSD".to_string()]; + cfg.save().unwrap(); + + let loaded = Config::load_from(&path).unwrap(); + assert_eq!( + loaded.ui.watch_cap_warned_roots, + vec!["/media/ApolloStore".to_string(), "/media/GSSD".to_string()] + ); + fs::remove_dir_all(&dir).ok(); + } + + /// Configs written before this field existed must still load. + #[test] + fn config_without_watch_cap_warned_roots_parses() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + fs::write( + &path, + "[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n[ui]\nscale=1.25\n", + ) + .unwrap(); + + let cfg = Config::load_from(&path).unwrap(); + assert!(cfg.ui.watch_cap_warned_roots.is_empty()); + assert_eq!(cfg.ui.scale, 1.25, "existing ui keys still parse"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn fuzzy_max_edits_round_trips() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + let mut cfg = Config::default(); + cfg.source = Some(path.clone()); + cfg.search.fuzzy_max_edits = 4; + cfg.save().unwrap(); + + let loaded = Config::load_from(&path).unwrap(); + assert_eq!(loaded.search.fuzzy_max_edits, 4); + fs::remove_dir_all(&dir).ok(); + } + + /// Configs written before this field existed keep the historic budget. + #[test] + fn config_without_fuzzy_max_edits_defaults_to_two() { + let dir = tmp_dir(); + let path = dir.join("config.toml"); + fs::write( + &path, + "[paths]\nindexing_paths=[\"/x\"]\ndatabase_path=\"db.sqlite\"\n\ + [search]\nfuzzy_default=true\ndisplay_limit=250\n", + ) + .unwrap(); + + let cfg = Config::load_from(&path).unwrap(); + assert_eq!(cfg.search.fuzzy_max_edits, 2); + assert!(cfg.search.fuzzy_default, "existing search keys still parse"); + assert_eq!(cfg.search.display_limit, 250); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn fuzzy_edits_warning_only_above_the_threshold() { + 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); + } + 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"); + assert!(msg.contains(&loud.to_string())); + assert!(msg.contains(&FUZZY_EDITS_WARN_ABOVE.to_string())); + } + } +} diff --git a/crates/quicksearch-core/src/coordinator.rs b/crates/quicksearch-core/src/coordinator.rs new file mode 100644 index 0000000..332ec63 --- /dev/null +++ b/crates/quicksearch-core/src/coordinator.rs @@ -0,0 +1,1019 @@ +//! The indexing coordinator: the one object binaries construct. +//! +//! Owns the [`IndexingService`] (full runs), the filesystem [`Watcher`] +//! (change events), a periodic-reindex scheduler, and the mode state +//! machine: +//! +//! - **Auto** — watcher running; events apply incrementally between full +//! runs; a full reindex triggers whenever `last_full_index` is older +//! than the configured interval (or has never happened). +//! - **ManualStopped** — watcher off, pending events dropped, nothing runs +//! until the user acts. +//! - **ManualRunning** — one user-forced full run; returns to +//! `ManualStopped` when it finishes. (A forced run in Auto stays Auto.) +//! +//! Single-writer guarantee: incremental writes are deferred while a full +//! run is active — the coordinator's tick simply does nothing until the +//! `IndexingService` reports idle, then drains its queue. Overflowing the +//! queue (>100k pending paths) collapses into one full run instead. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::Duration; + +use rusqlite::Connection; + +use crate::config::{Config, IgnoreSet}; +use crate::db; +use crate::extract::Registry; +use crate::incremental::apply_fs_event; +use crate::indexing::{ConfigChange, IndexingService, IndexingStatus}; +use crate::watcher::{FsEvent, WatchError, WatchFilters, Watcher, WatcherConfig}; + +/// Pending-event ceiling; beyond this a full run is cheaper than replay. +const PENDING_OVERFLOW: usize = 100_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndexMode { + Auto, + ManualStopped, + ManualRunning, +} + +/// Whether live updates are running, and if not, why. +/// +/// A failed watcher used to be a printed line the GUI never saw, so roots +/// silently fell back to the periodic reindex with no user-visible sign. +/// This is the state the UI acts on; the log line is the detail behind it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WatcherStatus { + /// Not running: manual mode, or no roots configured. + Off, + /// Registration in flight — it walks every root, so this can last + /// minutes on large or networked trees. + Starting, + /// Live updates active over `dirs` watched directories. + Active { dirs: usize }, + /// Live updates unavailable; the periodic reindex is the only refresh. + Disabled { reason: WatchError }, +} + +/// One-stop poll surface for the GUI. +#[derive(Debug, Clone)] +pub struct IndexerState { + pub mode: IndexMode, + pub activity: IndexingStatus, + /// Unix seconds of the last completed full run, if any. + pub last_full_index: Option, + /// Watcher events waiting to be applied. + pub queued_events: usize, + /// Live-update health; see [`WatcherStatus`]. + pub watcher: WatcherStatus, +} + +enum CoordCmd { + SetMode(IndexMode), + ReindexNow, + ConfigChanged(Config), + RebuildIndex, + ClearIndex, + Shutdown, +} + +pub struct IndexCoordinator { + cmd_tx: mpsc::Sender, + indexing: Arc, + shared: Arc>, + handle: Mutex>>, + stopped: AtomicBool, +} + +/// State mirrored out of the coordinator thread for `state()`. +struct Shared { + mode: IndexMode, + last_full_index: Option, + queued_events: usize, + watcher: WatcherStatus, +} + +impl IndexCoordinator { + pub fn start(config: Config) -> Result { + Self::start_with_watcher_config(config, WatcherConfig::default()) + } + + /// [`Self::start`] with explicit watcher debounce tuning (tests use + /// short windows; the default 30 s throttle is right for real use). + pub fn start_with_watcher_config( + config: Config, + watcher_config: WatcherConfig, + ) -> Result { + let indexing = Arc::new(IndexingService::new()); + let (cmd_tx, cmd_rx) = mpsc::channel(); + let (event_tx, event_rx) = mpsc::channel(); + + let initial_mode = if config.indexing.auto_index { + IndexMode::Auto + } else { + IndexMode::ManualStopped + }; + let shared = Arc::new(Mutex::new(Shared { + mode: initial_mode, + last_full_index: None, + queued_events: 0, + watcher: WatcherStatus::Off, + })); + + let mut inner = Inner { + config, + indexing: indexing.clone(), + shared: shared.clone(), + event_tx, + event_rx, + watcher: None, + watcher_config, + watcher_rx: None, + watcher_gen: 0, + pending: HashMap::new(), + needs_full_run: false, + saw_running: false, + write_conn: None, + ignore: Arc::new(IgnoreSet::compile(&[]).expect("empty ignore set")), + registry: Registry::default_set(), + mode: initial_mode, + }; + inner.reload_filters()?; + inner.refresh_last_full_index(); + + let handle = std::thread::Builder::new() + .name("qs-coordinator".into()) + .spawn(move || inner.run(cmd_rx)) + .map_err(|e| format!("spawn coordinator: {}", e))?; + + Ok(IndexCoordinator { + cmd_tx, + indexing, + shared, + handle: Mutex::new(Some(handle)), + stopped: AtomicBool::new(false), + }) + } + + pub fn state(&self) -> IndexerState { + let shared = self.shared.lock().unwrap(); + IndexerState { + mode: shared.mode, + activity: self.indexing.get_status(), + last_full_index: shared.last_full_index, + queued_events: shared.queued_events, + watcher: shared.watcher.clone(), + } + } + + pub fn set_mode(&self, mode: IndexMode) { + let _ = self.cmd_tx.send(CoordCmd::SetMode(mode)); + } + + /// Force a full reindex now. In manual mode the coordinator enters + /// `ManualRunning` and returns to `ManualStopped` when done. + pub fn reindex_now(&self) { + let _ = self.cmd_tx.send(CoordCmd::ReindexNow); + } + + /// Hand the coordinator an edited config. Watcher and paths follow on + /// the next tick; rebuild decisions stay with the caller (see + /// [`crate::config::diff_actions`] and [`Self::rebuild_index`]). + pub fn apply_config(&self, config: Config) { + let _ = self.cmd_tx.send(CoordCmd::ConfigChanged(config)); + } + + /// Delete the index and rebuild from scratch (user confirmed). + pub fn rebuild_index(&self) { + let _ = self.cmd_tx.send(CoordCmd::RebuildIndex); + } + + /// Delete the index WITHOUT rebuilding (user confirmed). Indexing + /// drops to manual-stopped so automatic mode doesn't immediately + /// resurrect what the user just deleted. + pub fn clear_index(&self) { + let _ = self.cmd_tx.send(CoordCmd::ClearIndex); + } + + /// Compare `config` against what the index was built with. Read-only. + pub fn check_config_validation(&self, config: &Config) -> Result>, String> { + let db = config.resolved_database_path(); + let roots = joined_roots(config); + self.indexing + .check_config_validation(&db.to_string_lossy(), config, &roots) + } + + /// Stop the watcher, any running index pass, and the coordinator + /// thread. Idempotent; usable from a signal handler through an Arc. + pub fn shutdown(&self) { + if self.stopped.swap(true, Ordering::SeqCst) { + return; + } + let _ = self.cmd_tx.send(CoordCmd::Shutdown); + if let Some(handle) = self.handle.lock().unwrap().take() { + let _ = handle.join(); + } + } +} + +impl Drop for IndexCoordinator { + fn drop(&mut self) { + self.shutdown(); + } +} + +/// Newline-joined resolved roots — the shape `start_indexing` / +/// `config_validation` store. +fn joined_roots(config: &Config) -> String { + config + .resolved_indexing_paths() + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect::>() + .join("\n") +} + +/// Fold `event` into the last-event-wins pending map. Renames split into +/// their halves so downstream application never needs pair handling. +fn enqueue(pending: &mut HashMap, event: FsEvent) { + match event { + FsEvent::Rename { from, to } => { + pending.insert(from.clone(), FsEvent::Remove(from)); + pending.insert(to.clone(), FsEvent::Create(to)); + } + FsEvent::Create(ref p) | FsEvent::Modify(ref p) | FsEvent::Remove(ref p) => { + let key = p.clone(); + pending.insert(key, event); + } + } +} + +struct Inner { + config: Config, + indexing: Arc, + shared: Arc>, + event_tx: mpsc::Sender, + event_rx: mpsc::Receiver, + watcher: Option, + watcher_config: WatcherConfig, + /// In-flight async watcher registration (see [`Inner::start_watcher`]). + watcher_rx: Option)>>, + watcher_gen: u64, + pending: HashMap, + needs_full_run: bool, + /// A start was requested; set false once the service reports running, + /// so idle-after-running transitions are detectable. + saw_running: bool, + write_conn: Option, + /// Shared with the watcher, which filters registrations by the same set. + ignore: Arc, + registry: Registry, + mode: IndexMode, +} + +impl Inner { + fn run(mut self, cmd_rx: mpsc::Receiver) { + if self.mode == IndexMode::Auto { + self.enter_auto(); + } + loop { + match cmd_rx.recv_timeout(Duration::from_secs(1)) { + Ok(CoordCmd::Shutdown) => break, + Ok(cmd) => self.handle_cmd(cmd), + Err(mpsc::RecvTimeoutError::Timeout) => self.tick(), + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + self.poll_watcher_startup(); + self.publish(); + } + self.teardown(); + } + + fn handle_cmd(&mut self, cmd: CoordCmd) { + match cmd { + CoordCmd::SetMode(IndexMode::Auto) => self.enter_auto(), + CoordCmd::SetMode(IndexMode::ManualStopped) => self.enter_manual_stopped(), + // ManualRunning isn't directly settable; ReindexNow is the verb. + CoordCmd::SetMode(IndexMode::ManualRunning) | CoordCmd::ReindexNow => { + self.start_full_run(); + if self.mode != IndexMode::Auto { + self.mode = IndexMode::ManualRunning; + } + } + CoordCmd::ConfigChanged(new) => { + self.config = new; + if let Err(e) = self.reload_filters() { + crate::log_warn!("coordinator: {}", e); + } + // The write connection may point at an old database_path. + self.write_conn = None; + // Watched roots / symlink behavior may have changed; a + // restart is cheap and unconditional beats a diff here. + if self.mode == IndexMode::Auto { + self.start_watcher(); + } + } + CoordCmd::RebuildIndex => { + let db = self.db_path(); + self.write_conn = None; + if let Err(e) = self.indexing.delete_index_for_rebuild(&db) { + crate::log_warn!("coordinator: rebuild: {}", e); + } + self.start_full_run(); + if self.mode != IndexMode::Auto { + self.mode = IndexMode::ManualRunning; + } + } + CoordCmd::ClearIndex => { + // Manual first: in Auto the periodic scheduler would see a + // missing index and rebuild what was just deleted. + self.enter_manual_stopped(); + self.write_conn = None; + let db = self.db_path(); + if let Err(e) = self.indexing.delete_index_for_rebuild(&db) { + crate::log_warn!("coordinator: clear index: {}", e); + } + self.shared.lock().unwrap().last_full_index = None; + } + CoordCmd::Shutdown => unreachable!("handled in run()"), + } + } + + fn tick(&mut self) { + self.check_watcher_degraded(); + self.drain_events(); + + let status = self.indexing.get_status(); + match status { + IndexingStatus::Running { .. } | IndexingStatus::Stopping => { + // Single-writer rule: never touch the DB while a full run + // is active; the queue drains on a later tick. + self.saw_running = true; + return; + } + IndexingStatus::Idle | IndexingStatus::Error(_) => {} + } + + // A run just finished — pick up its last_full_index stamp and + // resolve the manual-run mode. + if self.saw_running { + self.saw_running = false; + self.refresh_last_full_index(); + if self.mode == IndexMode::ManualRunning { + self.mode = IndexMode::ManualStopped; + } + } + + if self.mode != IndexMode::Auto { + if self.mode == IndexMode::ManualStopped { + self.pending.clear(); + } + return; + } + + if !self.pending.is_empty() && !self.needs_full_run { + self.apply_pending(); + } + + if self.needs_full_run || self.periodic_due() { + self.start_full_run(); + } + } + + fn drain_events(&mut self) { + while let Ok(ev) = self.event_rx.try_recv() { + enqueue(&mut self.pending, ev); + } + if self.pending.len() > PENDING_OVERFLOW { + // Replaying a storm one file at a time is slower than one + // incremental full run (unchanged files skip on mtime). + self.pending.clear(); + self.needs_full_run = true; + } + } + + fn apply_pending(&mut self) { + let events: Vec = self.pending.drain().map(|(_, ev)| ev).collect(); + let conn = match self.ensure_write_conn() { + Ok(conn) => conn, + Err(e) => { + // Missing or stale DB: incremental can't help, rebuild. + crate::log_warn!("coordinator: incremental unavailable ({}); scheduling full run", e); + self.needs_full_run = true; + return; + } + }; + // Borrow dance: pull the connection out while applying. + let mut conn = conn; + for ev in &events { + if let Err(e) = apply_fs_event(&mut conn, ev, &self.config, &self.ignore, &self.registry) + { + crate::log_warn!("coordinator: apply {:?}: {}", ev, e); + } + } + self.write_conn = Some(conn); + } + + fn ensure_write_conn(&mut self) -> Result { + if let Some(conn) = self.write_conn.take() { + return Ok(conn); + } + db::open_existing(&self.db_path(), true) + } + + fn periodic_due(&self) -> bool { + let interval_secs = self + .config + .indexing + .reindex_interval_minutes + .saturating_mul(60); + let last = self.shared.lock().unwrap().last_full_index; + match last { + None => true, + Some(last) => now_unix().saturating_sub(last) >= interval_secs, + } + } + + fn start_full_run(&mut self) { + let roots: Vec = self + .config + .resolved_indexing_paths() + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + if roots.is_empty() { + crate::log_warn!("coordinator: no indexing roots configured"); + return; + } + // Backstop for hand-edited configs; the GUI rejects nested roots at + // add/apply/startup with proper messaging. + let nested = crate::config::nested_roots(&roots); + if !nested.is_empty() { + for (child, parent) in &nested { + crate::log_warn!( + "coordinator: refusing to index: root {} is nested under {}", + child, parent + ); + } + return; + } + // The full run owns the DB (and may wipe/rebuild the file). + self.write_conn = None; + self.needs_full_run = false; + self.pending.clear(); + if let Err(e) = self + .indexing + .start_indexing(roots, self.db_path(), self.config.clone()) + { + crate::log_warn!("coordinator: start indexing: {}", e); + return; + } + // Give the service's command thread a moment to flip the status; + // small trees can finish between two coordinator ticks, and the + // finished-run bookkeeping keys off `saw_running`. + for _ in 0..200 { + if !matches!( + self.indexing.get_status(), + IndexingStatus::Idle | IndexingStatus::Error(_) + ) { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + self.saw_running = true; + } + + fn enter_auto(&mut self) { + self.mode = IndexMode::Auto; + self.start_watcher(); + if self.shared.lock().unwrap().last_full_index.is_none() { + self.needs_full_run = true; + } + } + + fn enter_manual_stopped(&mut self) { + self.mode = IndexMode::ManualStopped; + self.stop_watcher(); + self.pending.clear(); + let status = self.indexing.get_status(); + if !matches!(status, IndexingStatus::Idle | IndexingStatus::Error(_)) { + // Signal only — waiting up to 5 s here would stall every + // queued command behind the Stop click. + self.indexing.request_stop(); + } + } + + /// Begin watcher startup WITHOUT blocking the command loop. + /// Registering inotify watches walks every indexable directory of + /// every root — minutes on large or networked trees — and it used to + /// run inline here, wedging every queued command (Start/Stop/Apply) + /// behind it. The finished watcher is handed back through a channel + /// polled each loop turn; a generation counter discards superseded + /// registrations. + fn start_watcher(&mut self) { + self.stop_watcher(); + let roots = self.config.resolved_indexing_paths(); + if roots.is_empty() { + return; + } + let generation = self.watcher_gen; + let sink_tx = self.event_tx.clone(); + let sink = Arc::new(move |ev: FsEvent| { + let _ = sink_tx.send(ev); + }); + let config = self.watcher_config.clone(); + // Same filters the indexer walks with, so no descriptor is spent on + // a directory whose events would be discarded on arrival. + let filters = WatchFilters { + include_hidden: self.config.indexing.include_hidden, + follow_symlinks: self.config.indexing.follow_symlinks, + ignore: self.ignore.clone(), + }; + let (tx, rx) = mpsc::channel(); + self.watcher_rx = Some(rx); + self.set_watcher_status(WatcherStatus::Starting); + let spawned = std::thread::Builder::new() + .name("qs-watcher-start".into()) + .spawn(move || { + let result = Watcher::start(roots, filters, config, sink); + // A failed send means the coordinator moved on; dropping + // the watcher here unregisters it. + let _ = tx.send((generation, result)); + }); + if spawned.is_err() { + self.watcher_rx = None; + self.set_watcher_status(WatcherStatus::Off); + } + } + + /// Collect a finished watcher registration, if any. Called every + /// command-loop turn so it lands regardless of tick timing. + fn poll_watcher_startup(&mut self) { + let Some(rx) = &self.watcher_rx else { + return; + }; + match rx.try_recv() { + Ok((generation, result)) => { + self.watcher_rx = None; + if generation != self.watcher_gen { + return; // superseded; the watcher drops and unregisters + } + match result { + Ok(w) => { + let status = WatcherStatus::Active { + dirs: w.watched_dirs(), + }; + self.watcher = Some(w); + self.set_watcher_status(status); + } + Err(e) => { + // Not just a log line any more: the GUI needs this to + // tell the user live updates are off and only the + // periodic reindex is refreshing the index. + crate::log_warn!("coordinator: watcher: {}", e); + self.set_watcher_status(WatcherStatus::Disabled { reason: e }); + } + } + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => { + self.watcher_rx = None; + self.set_watcher_status(WatcherStatus::Off); + } + } + } + + /// Tear the watcher down if it ran out of watch budget after starting, + /// and schedule a full run so nothing missed while it degraded is left + /// stale. A partially watched tree looks live while going silently out + /// of date, so we prefer none at all plus periodic rescans. + fn check_watcher_degraded(&mut self) { + let Some(w) = &self.watcher else { + return; + }; + let Some(mut reason) = w.degraded_reason() else { + return; + }; + // The async notify callback can't know the count; fill it in here. + if let WatchError::KernelLimit { registered } = &mut reason { + if *registered == 0 { + *registered = w.watched_dirs(); + } + } + self.stop_watcher(); + self.needs_full_run = true; + self.set_watcher_status(WatcherStatus::Disabled { reason }); + } + + fn stop_watcher(&mut self) { + // Invalidate any in-flight registration and drop its channel. + self.watcher_gen = self.watcher_gen.wrapping_add(1); + self.watcher_rx = None; + if let Some(mut w) = self.watcher.take() { + w.stop(); + } + self.set_watcher_status(WatcherStatus::Off); + } + + fn set_watcher_status(&self, status: WatcherStatus) { + self.shared.lock().unwrap().watcher = status; + } + + fn db_path(&self) -> String { + self.config + .resolved_database_path() + .to_string_lossy() + .into_owned() + } + + fn reload_filters(&mut self) -> Result<(), String> { + self.ignore = Arc::new( + IgnoreSet::compile(&self.config.indexing.ignore_patterns) + .map_err(|e| format!("ignore patterns: {}", e))?, + ); + Ok(()) + } + + fn refresh_last_full_index(&self) { + let last = db::open_existing(&self.db_path(), false) + .ok() + .and_then(|conn| db::repo::get_last_full_index(&conn)); + self.shared.lock().unwrap().last_full_index = last; + } + + fn publish(&self) { + let mut shared = self.shared.lock().unwrap(); + shared.mode = self.mode; + shared.queued_events = self.pending.len(); + } + + /// Must stay fast: it runs (transitively) on the GUI thread during + /// window close, and desktops show a "terminate this application?" + /// dialog after a few unresponsive seconds. Signal, don't wait — an + /// abandoned run is safe under WAL. + fn teardown(mut self) { + self.stop_watcher(); + let status = self.indexing.get_status(); + let idle = matches!(status, IndexingStatus::Idle | IndexingStatus::Error(_)); + if !idle { + self.indexing.request_stop(); + } + if let Some(conn) = self.write_conn.take() { + if idle { + db::repo::checkpoint_and_close(conn); + } + // Otherwise just drop: a TRUNCATE checkpoint would block + // behind the running writer. + } + } +} + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wait_for bool>(what: &str, timeout: Duration, check: F) { + let deadline = std::time::Instant::now() + timeout; + while std::time::Instant::now() < deadline { + if check() { + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + panic!("timed out waiting for {}", what); + } + + struct Fixture { + dir: PathBuf, + db: PathBuf, + config: Config, + } + + impl Fixture { + fn new(auto: bool) -> Fixture { + let stamp = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let dir = std::env::temp_dir().join(format!("qs-coord-{}", stamp)); + std::fs::create_dir_all(&dir).unwrap(); + let db = std::env::temp_dir().join(format!("qs-coord-{}.sqlite", stamp)); + let mut config = Config::default(); + config.paths.indexing_paths = vec![dir.to_string_lossy().into_owned()]; + config.paths.database_path = db.to_string_lossy().into_owned(); + config.indexing.auto_index = auto; + Fixture { dir, db, config } + } + + fn file_count(&self) -> i64 { + match db::open_existing(&self.db.to_string_lossy(), false) { + Ok(conn) => conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap_or(0), + Err(_) => -1, + } + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.dir).ok(); + std::fs::remove_file(&self.db).ok(); + } + } + + #[test] + fn manual_mode_starts_idle_and_reindex_now_round_trips() { + let f = Fixture::new(false); + std::fs::write(f.dir.join("one.txt"), "manual mode content").unwrap(); + + let coord = IndexCoordinator::start(f.config.clone()).unwrap(); + assert_eq!(coord.state().mode, IndexMode::ManualStopped); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!(f.file_count(), -1, "no run without a command"); + + coord.reindex_now(); + wait_for("run to complete", Duration::from_secs(20), || { + let s = coord.state(); + s.last_full_index.is_some() && s.mode == IndexMode::ManualStopped + }); + assert_eq!(f.file_count(), 1); + coord.shutdown(); + } + + /// Short debounce windows so trailing-edge events flush within test + /// timeouts (production default is a 30 s window). + fn fast_watcher() -> WatcherConfig { + WatcherConfig { + throttle_window: Duration::from_millis(300), + tick_interval: Duration::from_millis(100), + ..WatcherConfig::default() + } + } + + #[test] + fn auto_mode_runs_initial_index_and_applies_watcher_events() { + let f = Fixture::new(true); + std::fs::write(f.dir.join("seed.txt"), "initial content").unwrap(); + + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap(); + wait_for("initial auto index", Duration::from_secs(20), || { + coord.state().last_full_index.is_some() && f.file_count() == 1 + }); + + // New file → watcher event → incremental application. + std::fs::write(f.dir.join("later.txt"), "arrived later").unwrap(); + wait_for("incremental add", Duration::from_secs(20), || { + f.file_count() == 2 + }); + + // Deletion sweeps the row. + std::fs::remove_file(f.dir.join("later.txt")).unwrap(); + wait_for("incremental remove", Duration::from_secs(20), || { + f.file_count() == 1 + }); + + coord.shutdown(); + } + + /// A healthy watcher reports its own size, so the GUI can say + /// "watching N folders" instead of guessing. + #[test] + fn auto_mode_reports_an_active_watcher() { + let f = Fixture::new(true); + std::fs::create_dir_all(f.dir.join("sub")).unwrap(); + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap(); + + wait_for("watcher active", Duration::from_secs(20), || { + matches!(coord.state().watcher, WatcherStatus::Active { .. }) + }); + match coord.state().watcher { + WatcherStatus::Active { dirs } => assert_eq!(dirs, 2, "root + sub"), + other => panic!("expected Active, got {:?}", other), + } + coord.shutdown(); + } + + /// The regression this whole change exists for: exceeding the watch + /// budget must surface as `Disabled` rather than a stderr line the GUI + /// never sees — and the periodic reindex must keep the index fresh. + #[test] + fn exceeding_the_watch_cap_disables_updates_but_keeps_indexing() { + let f = Fixture::new(true); + for sub in ["a", "b", "c"] { + std::fs::create_dir_all(f.dir.join(sub)).unwrap(); + } + std::fs::write(f.dir.join("seed.txt"), "content").unwrap(); + + let watcher_config = WatcherConfig { + max_watched_dirs: 2, + ..fast_watcher() + }; + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), watcher_config).unwrap(); + + wait_for("watcher disabled", Duration::from_secs(20), || { + matches!(coord.state().watcher, WatcherStatus::Disabled { .. }) + }); + match coord.state().watcher { + WatcherStatus::Disabled { reason } => assert_eq!( + reason, + WatchError::TooManyDirectories { dirs: 2, cap: 2 }, + "the cap, not some other failure" + ), + other => panic!("expected Disabled, got {:?}", other), + } + + // 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 + }); + coord.shutdown(); + } + + #[test] + fn manual_mode_reports_the_watcher_off() { + let f = Fixture::new(false); + let coord = IndexCoordinator::start(f.config.clone()).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!(coord.state().watcher, WatcherStatus::Off); + coord.shutdown(); + } + + #[test] + fn stopping_turns_the_watcher_status_off() { + let f = Fixture::new(true); + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap(); + wait_for("watcher active", Duration::from_secs(20), || { + matches!(coord.state().watcher, WatcherStatus::Active { .. }) + }); + + coord.set_mode(IndexMode::ManualStopped); + wait_for("watcher off", Duration::from_secs(10), || { + coord.state().watcher == WatcherStatus::Off + }); + coord.shutdown(); + } + + #[test] + fn manual_stop_drops_watcher_and_events() { + let f = Fixture::new(true); + std::fs::write(f.dir.join("seed.txt"), "content").unwrap(); + let coord = IndexCoordinator::start(f.config.clone()).unwrap(); + wait_for("initial index", Duration::from_secs(20), || { + f.file_count() == 1 + }); + + coord.set_mode(IndexMode::ManualStopped); + wait_for("mode switch", Duration::from_secs(5), || { + coord.state().mode == IndexMode::ManualStopped + }); + + std::fs::write(f.dir.join("unseen.txt"), "never indexed").unwrap(); + std::thread::sleep(Duration::from_secs(3)); + assert_eq!(f.file_count(), 1, "stopped mode must not index new files"); + assert_eq!(coord.state().queued_events, 0); + + coord.shutdown(); + } + + #[test] + fn apply_config_with_new_root_then_reindex_indexes_it() { + // The reported failure: add directories, apply, click "Start + // indexing now" — the run must pick up the new roots promptly + // (watcher re-registration happens concurrently, never blocking + // the command loop). + let f = Fixture::new(true); + std::fs::write(f.dir.join("first.txt"), "one").unwrap(); + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap(); + wait_for("initial index", Duration::from_secs(20), || { + f.file_count() == 1 + }); + + 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(); + + let mut new_cfg = f.config.clone(); + new_cfg + .paths + .indexing_paths + .push(extra_root.to_string_lossy().into_owned()); + coord.apply_config(new_cfg); + coord.reindex_now(); + + wait_for("new root indexed", Duration::from_secs(20), || { + f.file_count() == 2 + }); + coord.shutdown(); + std::fs::remove_dir_all(&extra_root).ok(); + } + + #[test] + fn enqueue_last_wins_and_rename_splits() { + let mut pending = HashMap::new(); + let a = PathBuf::from("/x/a"); + enqueue(&mut pending, FsEvent::Create(a.clone())); + enqueue(&mut pending, FsEvent::Modify(a.clone())); + assert_eq!(pending.len(), 1); + assert!(matches!(pending.get(&a), Some(FsEvent::Modify(_)))); + + enqueue( + &mut pending, + FsEvent::Rename { + from: a.clone(), + to: PathBuf::from("/x/b"), + }, + ); + assert_eq!(pending.len(), 2); + assert!(matches!(pending.get(&a), Some(FsEvent::Remove(_)))); + assert!(matches!( + pending.get(&PathBuf::from("/x/b")), + Some(FsEvent::Create(_)) + )); + } + + #[test] + fn clear_index_deletes_db_and_stays_manual() { + let f = Fixture::new(true); // auto mode — clear must not auto-resurrect + std::fs::write(f.dir.join("a.txt"), "content").unwrap(); + let coord = + IndexCoordinator::start_with_watcher_config(f.config.clone(), fast_watcher()).unwrap(); + wait_for("initial index", Duration::from_secs(20), || { + f.file_count() == 1 + }); + + coord.clear_index(); + wait_for("index deleted", Duration::from_secs(10), || { + f.file_count() == -1 // open_existing fails: file gone + }); + wait_for("manual mode", Duration::from_secs(5), || { + coord.state().mode == IndexMode::ManualStopped + }); + assert_eq!(coord.state().last_full_index, None); + + // Give the (now manual) coordinator a few ticks: the index must + // stay deleted rather than being rebuilt by the scheduler. + std::thread::sleep(Duration::from_secs(3)); + assert_eq!(f.file_count(), -1, "cleared index must stay cleared"); + + coord.shutdown(); + } + + #[test] + fn nested_roots_refuse_to_run() { + let f = Fixture::new(false); + let child = f.dir.join("nested"); + std::fs::create_dir_all(&child).unwrap(); + std::fs::write(child.join("x.txt"), "content").unwrap(); + + let mut config = f.config.clone(); + config + .paths + .indexing_paths + .push(child.to_string_lossy().into_owned()); + let coord = IndexCoordinator::start(config).unwrap(); + coord.reindex_now(); + std::thread::sleep(Duration::from_secs(3)); + assert_eq!( + f.file_count(), + -1, + "a run over nested roots must be refused (no DB created)" + ); + coord.shutdown(); + } + + #[test] + fn shutdown_is_idempotent_and_joins() { + let f = Fixture::new(false); + let coord = IndexCoordinator::start(f.config.clone()).unwrap(); + coord.shutdown(); + coord.shutdown(); // second call is a no-op + } +} diff --git a/crates/quicksearch-core/src/db/mod.rs b/crates/quicksearch-core/src/db/mod.rs index d0be283..062be97 100644 --- a/crates/quicksearch-core/src/db/mod.rs +++ b/crates/quicksearch-core/src/db/mod.rs @@ -1,13 +1,15 @@ //! SQLite schema, on-disk open/recreate, and row-level repository helpers. //! -//! Policy: a single [`open::open_or_recreate`] is the only entry point. Any -//! schema mismatch — wrong version, drifted tokenizer, absent `schema_info` -//! — wipes the DB and rebuilds from [`schema::SCHEMA_CURRENT`]. There are -//! no in-place migrations by design; re-indexing is accepted as the cost -//! of avoiding migration-path complexity. +//! Policy: the indexer (owner) opens via [`open::open_or_recreate`], which on +//! any schema mismatch — wrong version, drifted tokenizer, absent +//! `schema_info` — wipes the DB and rebuilds from [`schema::SCHEMA_CURRENT`]. +//! There are no in-place migrations by design; re-indexing is accepted as the +//! cost of avoiding migration-path complexity. *Consumers* (search, status, +//! size, `clear`) instead use [`open::open_existing`], which never creates or +//! wipes — a tokenizer difference or stale version is an error, not data loss. pub mod open; pub mod repo; pub mod schema; -pub use open::{open_or_recreate, CURRENT_SCHEMA_VERSION}; +pub use open::{open_existing, open_or_recreate, CURRENT_SCHEMA_VERSION}; diff --git a/crates/quicksearch-core/src/db/open.rs b/crates/quicksearch-core/src/db/open.rs index b6cd06c..15db713 100644 --- a/crates/quicksearch-core/src/db/open.rs +++ b/crates/quicksearch-core/src/db/open.rs @@ -15,9 +15,11 @@ use std::path::Path; -use rusqlite::{params, Connection, OptionalExtension}; +use rusqlite::{params, Connection, OpenFlags, OptionalExtension}; -use super::schema::{effective_tokenizer, fts_create_sql, PRAGMAS_FAST, SCHEMA_CURRENT}; +use super::schema::{ + effective_tokenizer, fts_create_sql, PRAGMAS_FAST, PRAGMAS_READONLY, SCHEMA_CURRENT, +}; /// Bump this whenever [`SCHEMA_CURRENT`] or [`fts_create_sql`] changes in /// a way that makes an old DB unreadable by new code. Any such bump @@ -34,6 +36,14 @@ pub const CURRENT_SCHEMA_VERSION: u32 = 3; /// schema mismatch and triggers the wipe-and-recreate path. pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result { let path = Path::new(db_path).to_path_buf(); + // The owner creates the directory too — a fresh install's default + // XDG data dir doesn't exist until first use. + if let Some(dir) = path.parent() { + if !dir.as_os_str().is_empty() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("Failed to create database dir {}: {}", dir.display(), e))?; + } + } let conn = Connection::open(db_path) .map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?; conn.execute_batch(PRAGMAS_FAST) @@ -46,8 +56,8 @@ pub fn open_or_recreate(db_path: &str, tokenizer: &str) -> Result Result Result { +/// Open an *existing* index without ever recreating it. Verifies the schema +/// version matches this build; on any mismatch — missing file, no +/// `schema_info`, wrong version — returns an error instead of wiping. The +/// on-disk FTS tokenizer is used as-is: a tokenizer difference is never a +/// reason to destroy a readable index. +/// +/// `write == false` opens read-only; `write == true` opens read-write (for +/// row-level deletes like `clear`) but still never creates or wipes — there +/// is no `SQLITE_OPEN_CREATE`, so a missing file is a clean error. +/// +/// Use this for every *consumer* (search, status, size, `clear`). Only the +/// indexer's own write path uses [`open_or_recreate`], which may wipe on a +/// genuine schema/tokenizer change it owns. +pub fn open_existing(db_path: &str, write: bool) -> Result { + let flags = OpenFlags::SQLITE_OPEN_NO_MUTEX + | if write { + OpenFlags::SQLITE_OPEN_READ_WRITE + } else { + OpenFlags::SQLITE_OPEN_READ_ONLY + }; + let conn = Connection::open_with_flags(db_path, flags) + .map_err(|e| format!("Failed to open database at {}: {}", db_path, e))?; + let pragmas = if write { PRAGMAS_FAST } else { PRAGMAS_READONLY }; + conn.execute_batch(pragmas) + .map_err(|e| format!("Failed to apply pragmas: {}", e))?; + + if !schema_version_current(&conn)? { + return Err(format!( + "index at {} is not a compatible QuickSearch index (schema v{} expected); \ + refusing to modify it. Re-index to rebuild.", + db_path, CURRENT_SCHEMA_VERSION + )); + } + Ok(conn) +} + +/// True iff the DB has a `schema_info` table whose `version` equals +/// [`CURRENT_SCHEMA_VERSION`]. Shared by the wipe decision +/// ([`db_matches_current`]) and the non-destructive [`open_existing`] path. +/// Deliberately ignores the tokenizer — that's only the owner's concern. +fn schema_version_current(conn: &Connection) -> Result { let has_info: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='schema_info'", @@ -81,8 +128,14 @@ fn db_matches_current(conn: &Connection, tokenizer: &str) -> Result Result { + if !schema_version_current(conn)? { return Ok(false); } @@ -104,10 +157,22 @@ fn wipe_and_reopen(conn: Connection, path: &Path) -> Result drop(conn); // Primary file may already be absent (fresh open that just needed // the table applied). Ignore NotFound; anything else is an error. - match std::fs::remove_file(path) { + // + // `remove_file_retrying` matters on Windows, where a delete fails while + // *any* handle is open — most often an antivirus scanner reading the file + // in the moment after we closed it. Unix `unlink` never hits this, so the + // retry costs nothing there. + match crate::platform::remove_file_retrying(path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(format!("Failed to remove old database: {}", e)), + Err(e) => { + return Err(format!( + "Failed to remove old database at {}: {}. \ + Another QuickSearch instance may have the index open.", + path.display(), + e + )) + } } // Sidecars are optional — delete best-effort. for suffix in ["-wal", "-shm", "-journal"] { @@ -116,7 +181,7 @@ fn wipe_and_reopen(conn: Connection, path: &Path) -> Result path.file_name().and_then(|s| s.to_str()).unwrap_or(""), suffix )); - let _ = std::fs::remove_file(sidecar); + let _ = crate::platform::remove_file_retrying(&sidecar); } let conn = Connection::open(path) .map_err(|e| format!("Failed to reopen database after rebuild: {}", e))?; @@ -326,4 +391,166 @@ mod tests { drop(conn); std::fs::remove_file(&p).ok(); } + + #[test] + fn open_existing_reads_nondefault_tokenizer_without_wiping() { + // The exact scenario that previously caused data loss: an index built + // with a non-default tokenizer, then opened by a *consumer* that only + // knows "trigram". `open_existing` must read it as-is and never wipe. + let p = tmp_db_path(); + { + let conn = open_or_recreate(p.to_str().unwrap(), "unicode61").unwrap(); + conn.execute( + "INSERT INTO files (name, path, parent, size, mtime) \ + VALUES ('note', '/note.txt', '/', 0, 0)", + [], + ) + .unwrap(); + // Seed the FTS index (rowid = the files row we just inserted) so a + // MATCH query can be exercised against the on-disk tokenizer. + conn.execute( + "INSERT INTO searchabletext (rowid, name, text, properties) \ + VALUES (last_insert_rowid(), 'note', 'hello world', '')", + [], + ) + .unwrap(); + } + + let conn = open_existing(p.to_str().unwrap(), false).unwrap(); + let files: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!( + files, 1, + "open_existing must not wipe a non-default-tokenizer DB" + ); + // The on-disk tokenizer is used as-is: a MATCH against the stored term + // returns the row. + let hits: i64 = conn + .query_row( + "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH 'hello'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(hits, 1); + // And the stored tokenizer is still the non-default one — proof we + // neither rewrote the FTS table nor reset schema_info. + let tok: String = conn + .query_row( + "SELECT value FROM schema_info WHERE key='tokenize'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tok, "unicode61"); + drop(conn); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn open_or_recreate_creates_missing_parent_dirs() { + // Fresh installs point at ~/.local/share/quicksearch/… which + // doesn't exist yet; the owner open must create it. + let mut dir = std::env::temp_dir(); + dir.push(format!( + "qs-mkdir-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let db = dir.join("nested/deeper/index.sqlite"); + let conn = open_or_recreate(db.to_str().unwrap(), "trigram").unwrap(); + drop(conn); + assert!(db.exists()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn writable_opens_use_wal_and_it_persists() { + let p = tmp_db_path(); + { + let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + assert_eq!(mode.to_lowercase(), "wal"); + } + // WAL is persistent in the file: a later read-only consumer sees it + // without being able to (or needing to) set it. + let conn = open_existing(p.to_str().unwrap(), false).unwrap(); + let mode: String = conn + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + assert_eq!(mode.to_lowercase(), "wal"); + drop(conn); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn open_existing_errors_on_missing_file() { + let p = tmp_db_path(); + assert!(!p.exists()); + let res = open_existing(p.to_str().unwrap(), false); + assert!(res.is_err(), "missing file must error, not be created"); + assert!(!p.exists(), "open_existing must not create the file"); + } + + #[test] + fn open_existing_errors_on_version_mismatch_without_wiping() { + // A DB from a prior schema version. A consumer opening it must get an + // error and leave the file untouched — the data is the owner's to + // rebuild, never a reader's to destroy. + let p = tmp_db_path(); + { + let conn = Connection::open(&p).unwrap(); + conn.execute( + "CREATE TABLE schema_info (key TEXT PRIMARY KEY, value TEXT NOT NULL)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO schema_info(key,value) VALUES('version','1')", + [], + ) + .unwrap(); + conn.execute("CREATE TABLE files (id INTEGER PRIMARY KEY, name TEXT)", []) + .unwrap(); + conn.execute("INSERT INTO files(name) VALUES('sentinel')", []) + .unwrap(); + } + let res = open_existing(p.to_str().unwrap(), false); + assert!(res.is_err(), "stale schema version must error"); + // Sentinel row still present → the file was not wiped. + let conn = Connection::open(&p).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1, "open_existing must never delete on version mismatch"); + drop(conn); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn open_existing_rw_allows_delete() { + let p = tmp_db_path(); + { + let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + conn.execute( + "INSERT INTO files (name, path, parent, size, mtime) \ + VALUES ('a', '/a', '/', 0, 0)", + [], + ) + .unwrap(); + } + let conn = open_existing(p.to_str().unwrap(), true).unwrap(); + let removed = conn + .execute("DELETE FROM files WHERE path = '/a'", []) + .unwrap(); + assert_eq!(removed, 1); + drop(conn); + std::fs::remove_file(&p).ok(); + } } diff --git a/crates/quicksearch-core/src/db/repo.rs b/crates/quicksearch-core/src/db/repo.rs index 88cb2ca..6b6a432 100644 --- a/crates/quicksearch-core/src/db/repo.rs +++ b/crates/quicksearch-core/src/db/repo.rs @@ -278,17 +278,41 @@ fn encode_properties_for_fts(props: &[(String, String)]) -> String { buf } -/// Flush and close a connection, restoring durable PRAGMAs. Call on clean -/// shutdown so the next open sees a consistent DB. +/// Flush the WAL into the main DB file and close. Call on clean shutdown so +/// the next open starts with an empty log. WAL mode itself is persistent in +/// the file — deliberately left on. pub fn checkpoint_and_close(conn: Connection) { - let _ = conn.execute_batch( - "PRAGMA journal_mode = DELETE; \ - PRAGMA synchronous = FULL; \ - PRAGMA wal_checkpoint(FULL);", - ); + let _ = conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);"); drop(conn); } +/// Read the `last_full_index` marker (unix seconds of the last *successful* +/// full indexing run) from `schema_info`. Absent key — fresh DB, or a DB +/// from before this marker existed — means "never". +pub fn get_last_full_index(conn: &Connection) -> Option { + conn.query_row( + "SELECT value FROM schema_info WHERE key = 'last_full_index'", + [], + |r| r.get::<_, String>(0), + ) + .optional() + .ok() + .flatten() + .and_then(|v| v.parse().ok()) +} + +/// Stamp `last_full_index` with `ts` (unix seconds). Called at the end of +/// every successful full indexing run; the coordinator reads it to schedule +/// periodic reindexing. +pub fn set_last_full_index(conn: &Connection, ts: u64) -> Result<(), String> { + conn.execute( + "INSERT OR REPLACE INTO schema_info(key, value) VALUES ('last_full_index', ?1)", + params![ts.to_string()], + ) + .map_err(|e| format!("write last_full_index: {}", e))?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; @@ -480,6 +504,59 @@ mod tests { std::fs::remove_file(&p).ok(); } + #[test] + fn last_full_index_round_trip() { + let p = tmp_path(); + let conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + assert_eq!(get_last_full_index(&conn), None, "fresh DB has no marker"); + set_last_full_index(&conn, 1_700_000_123).unwrap(); + assert_eq!(get_last_full_index(&conn), Some(1_700_000_123)); + // Overwrite, not accumulate. + set_last_full_index(&conn, 1_700_000_999).unwrap(); + assert_eq!(get_last_full_index(&conn), Some(1_700_000_999)); + drop(conn); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn checkpoint_and_close_truncates_wal() { + let p = tmp_path(); + let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + { + let tx = conn.transaction().unwrap(); + insert_file( + &tx, + &NewFile { + name: "w.txt", + path: "/tmp/w.txt", + parent: "/tmp", + size: 1, + mtime: 1, + inode: None, + device_id: None, + mime: None, + ftype: FileType::EMPTY, + hash: None, + }, + ) + .unwrap(); + tx.commit().unwrap(); + } + checkpoint_and_close(conn); + // After a TRUNCATE checkpoint + close of the last connection the WAL + // sidecar is gone or empty; the row lives in the main file. + let wal = std::path::PathBuf::from(format!("{}-wal", p.display())); + let wal_len = std::fs::metadata(&wal).map(|m| m.len()).unwrap_or(0); + assert_eq!(wal_len, 0, "WAL should be truncated on clean close"); + let conn = crate::db::open_existing(p.to_str().unwrap(), false).unwrap(); + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + drop(conn); + std::fs::remove_file(&p).ok(); + } + #[test] fn set_content_failed_writes_failed_table() { let p = tmp_path(); diff --git a/crates/quicksearch-core/src/db/schema.rs b/crates/quicksearch-core/src/db/schema.rs index dbb6abd..afd96b1 100644 --- a/crates/quicksearch-core/src/db/schema.rs +++ b/crates/quicksearch-core/src/db/schema.rs @@ -1,12 +1,32 @@ //! SQL strings for the current schema. Versioned; [`migrate`](super::migrate) //! drives the upgrade path. -/// Pragmas applied on every connection open. Tuned for write throughput during -/// indexing; a clean shutdown re-enables journal_mode/synchronous via +/// Pragmas applied on every writable connection open. +/// +/// WAL, not journal-off: auto-indexing writes continuously while searches +/// stream from their own read-only connections, and WAL is what lets those +/// readers proceed without ever blocking the writer (or vice versa). +/// `synchronous = NORMAL` under WAL risks only the last commit on power +/// loss — acceptable for an index that is re-derivable from disk. Only two +/// writers exist (full index runs and the coordinator's incremental +/// updates) and they're serialized by design; `busy_timeout` is a backstop, +/// not a coordination mechanism. A clean shutdown truncates the log via /// [`super::repo::checkpoint_and_close`]. pub const PRAGMAS_FAST: &str = " - PRAGMA journal_mode = OFF; - PRAGMA synchronous = 0; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA busy_timeout = 5000; + PRAGMA cache_size = 10000; + PRAGMA temp_store = MEMORY; + PRAGMA foreign_keys = ON; +"; + +/// Pragmas safe to apply on a read-only connection, where `journal_mode` +/// and `synchronous` can't be changed on the file. Used by +/// [`super::open::open_existing`] for read-only opens; write paths get the +/// full [`PRAGMAS_FAST`] set. +pub const PRAGMAS_READONLY: &str = " + PRAGMA busy_timeout = 5000; PRAGMA cache_size = 10000; PRAGMA temp_store = MEMORY; PRAGMA foreign_keys = ON; diff --git a/crates/quicksearch-core/src/extract/mod.rs b/crates/quicksearch-core/src/extract/mod.rs index 6c089d9..d4f14f3 100644 --- a/crates/quicksearch-core/src/extract/mod.rs +++ b/crates/quicksearch-core/src/extract/mod.rs @@ -2,8 +2,15 @@ //! //! An [`Extractor`] decides whether it can handle a given MIME type and, if //! so, produces [`ExtractedContent`] for the file. The [`Registry`] picks the -//! first registered extractor that accepts the MIME and runs it. Callers can -//! also fall back to an extension-based match for files with no detected MIME. +//! first registered extractor that accepts the MIME and runs it. +//! +//! Dispatch is by MIME only — a file with no detected type is recorded as +//! "not applicable" rather than guessed at again here. Extensions that +//! `mime_guess` misses or mistypes are corrected upstream instead, in +//! [`crate::mime::guess_mime_from_head`], so there is one place where "what is +//! this file" gets decided, and it is decided once: the walk sniffs the head it +//! already read, stores the answer, and nothing downstream reopens the file to +//! ask again. use std::collections::HashMap; use std::path::Path; @@ -70,6 +77,32 @@ pub trait Extractor: Send + Sync { /// [`ExtractError`] to mark the file's content state as failed (so it /// won't be retried every run). fn extract(&self, path: &Path) -> Result; + + /// Extract from bytes the caller already holds, when those bytes are the + /// file's *entire* contents. + /// + /// Indexing hashes the head of every new or changed file, so for anything + /// no larger than `hash_length` the whole file is already in memory by the + /// time the walk classifies it. An extractor that can work from that buffer + /// saves the content pass an open/read/close — several round trips per + /// file on a network share — and closes a consistency gap, because the + /// text then comes from the same `read` as the size, mtime and hash stored + /// alongside it. + /// + /// The default is `None`: "I need the file on disk." Formats that seek, + /// or that read a central directory at the end of the file, must keep it. + /// Returning `Some(Err(_))` is a real extraction failure, recorded like + /// any other; returning `None` simply defers to [`Extractor::extract`]. + /// + /// `path` is passed only so failures name the same file the on-disk path + /// would — nothing here may open it. + fn extract_from_head( + &self, + _path: &Path, + _head: &[u8], + ) -> Option> { + None + } } /// An ordered dispatch table of extractors. The first extractor whose @@ -105,6 +138,28 @@ impl Registry { Ok(None) } + /// [`Registry::extract`] for a file whose complete contents the caller + /// already holds. `Ok(None)` when no extractor claims the MIME, and + /// `None` when the one that does needs the file on disk after all — + /// both mean "leave this to the content pass". + /// + /// Dispatch stays here rather than at the call site so there is still + /// exactly one place that decides what an extractor sees for a given MIME. + pub fn extract_complete_head( + &self, + path: &Path, + mime: &str, + head: &[u8], + ) -> Option> { + let lower = mime.to_ascii_lowercase(); + for e in &self.extractors { + if e.supports(&lower) { + return e.extract_from_head(path, head); + } + } + None + } + /// The default set wired up for Set A: plaintext, office docs, PDF, /// audio tags, image EXIF. pub fn default_set() -> Self { @@ -136,6 +191,38 @@ mod tests { assert!(out.is_none()); } + #[test] + fn complete_head_extraction_dispatches_only_to_extractors_that_opt_in() { + let r = Registry::default_set(); + let p = Path::new("/tmp/whatever"); + + // Plaintext opts in, so a small text file never reaches the disk pass. + let out = r.extract_complete_head(p, "text/plain", b"hello"); + assert!(matches!(out, Some(Ok(ref c)) if c.text == "hello")); + + // 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()); + + // No extractor claims the MIME at all. + assert!(r.extract_complete_head(p, "application/x-nonesuch", b"..").is_none()); + } + + #[test] + fn complete_head_extraction_matches_the_on_disk_dispatch() { + // Both entry points must pick the same extractor for a MIME, or a + // 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"] { + assert!( + r.extract_complete_head(p, mime, b"x").is_some(), + "{} should extract from a head", mime + ); + } + } + #[test] fn properties_sorted_is_deterministic() { let c = ExtractedContent::with_text("hi") diff --git a/crates/quicksearch-core/src/extract/pdf.rs b/crates/quicksearch-core/src/extract/pdf.rs index ae7c511..681c85a 100644 --- a/crates/quicksearch-core/src/extract/pdf.rs +++ b/crates/quicksearch-core/src/extract/pdf.rs @@ -6,12 +6,48 @@ //! failed. Properties (title, author, etc.) from the PDF `Info` dictionary //! are pulled via `lopdf` where available. +use std::cell::Cell; use std::path::Path; +use std::sync::OnceLock; use lopdf::{Document as LopdfDocument, Object}; use super::{ExtractError, ExtractedContent, Extractor}; +thread_local! { + /// True while this thread is inside a contained `pdf_extract` call. + static SUPPRESS_PANIC_PRINT: Cell = const { Cell::new(false) }; +} + +/// Chain a process panic hook (once) that swallows the default +/// "thread panicked at …" report while this thread is inside a *contained* +/// PDF extraction — those panics are expected on malformed PDFs, caught, +/// and recorded as the file's failure reason, so printing each one is pure +/// console spam. Panics anywhere else print exactly as before. +fn install_quiet_panic_hook() { + static INSTALLED: OnceLock<()> = OnceLock::new(); + INSTALLED.get_or_init(|| { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + if !SUPPRESS_PANIC_PRINT.with(|flag| flag.get()) { + previous(info); + } + })); + }); +} + +/// Human-readable message from a caught panic payload; lands in +/// `failed_files.reason`. +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "unknown panic".to_string() + } +} + pub struct PdfExtractor; impl Extractor for PdfExtractor { @@ -20,10 +56,15 @@ impl Extractor for PdfExtractor { } fn extract(&self, path: &Path) -> Result { - // Text. Catch panics from pdf_extract (some PDFs crash its parser). + // Text. Catch panics from pdf_extract (some PDFs crash its parser) + // and keep the default hook from spamming stderr about them. + install_quiet_panic_hook(); let path_buf = path.to_path_buf(); - let text = std::panic::catch_unwind(move || pdf_extract::extract_text(&path_buf)) - .map_err(|_| "pdf_extract panicked".to_string())? + SUPPRESS_PANIC_PRINT.with(|flag| flag.set(true)); + let result = std::panic::catch_unwind(move || pdf_extract::extract_text(&path_buf)); + SUPPRESS_PANIC_PRINT.with(|flag| flag.set(false)); + let text = result + .map_err(|panic| format!("pdf_extract panicked: {}", panic_message(&*panic)))? .map_err(|e| format!("pdf_extract: {}", e))?; let mut out = ExtractedContent::with_text(text); @@ -69,6 +110,20 @@ fn object_to_string(obj: &Object) -> Option { mod tests { use super::*; + #[test] + fn contained_panics_are_caught_quietly_with_reason() { + install_quiet_panic_hook(); + SUPPRESS_PANIC_PRINT.with(|flag| flag.set(true)); + let result = std::panic::catch_unwind(|| panic!("synthetic pdf failure")); + SUPPRESS_PANIC_PRINT.with(|flag| flag.set(false)); + let payload = result.expect_err("must panic"); + assert_eq!(panic_message(&*payload), "synthetic pdf failure"); + // Panics outside the suppression window keep printing: the flag is + // thread-local and cleared, so nothing here can silence other + // threads or later tests. + assert!(!SUPPRESS_PANIC_PRINT.with(|flag| flag.get())); + } + #[test] fn supports_pdf_mime() { assert!(PdfExtractor.supports("application/pdf")); diff --git a/crates/quicksearch-core/src/extract/plaintext.rs b/crates/quicksearch-core/src/extract/plaintext.rs index e20aaff..4a721f0 100644 --- a/crates/quicksearch-core/src/extract/plaintext.rs +++ b/crates/quicksearch-core/src/extract/plaintext.rs @@ -1,10 +1,21 @@ //! Read the file as UTF-8 text. Handles text/plain, text/x-*, application/json //! and most source-code MIMEs. +use std::fs::File; +use std::io::Read; use std::path::Path; use super::{ExtractError, ExtractedContent, Extractor}; +/// Decode bytes that are known to be a complete file. Shared by both entry +/// points so on-disk and already-in-memory extraction cannot drift apart. +fn decode(bytes: Vec, path: &Path) -> Result { + match String::from_utf8(bytes) { + Ok(text) => Ok(ExtractedContent::with_text(text)), + Err(e) => Err(format!("plaintext read {}: {}", path.display(), e.utf8_error())), + } +} + pub struct PlaintextExtractor; impl Extractor for PlaintextExtractor { @@ -22,13 +33,66 @@ impl Extractor for PlaintextExtractor { | "application/toml" | "application/yaml" | "application/x-yaml" + // `.sql` resolves here rather than to `text/*`, so without it + // schema dumps are listed by name but never full-text indexed. + | "application/x-sql" ) } + /// Read the whole file, sized from the handle we just opened. + /// + /// `std::fs::read_to_string` would cost two extra syscalls here: a + /// path-based `statx` to size its buffer, and a second `read` returning 0, + /// because "read to EOF" can only observe EOF that way — `read_to_end` + /// terminates on `Ok(0)` alone, so a short read does not end it. Sizing + /// the buffer ourselves lets the loop finish on `filled == size` and issue + /// exactly one `read` for a file that fits. + /// + /// A file that shrank between the `fstat` and the `read` keeps its prefix + /// rather than failing. A file that grew is read up to the size we saw; + /// its mtime moved, so the next run reclassifies it as changed and + /// re-extracts (see [`crate::file_handling::classify_for_indexing`]). + /// 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 text = std::fs::read_to_string(path) + let mut f = File::open(path) .map_err(|e| format!("plaintext read {}: {}", path.display(), e))?; - Ok(ExtractedContent::with_text(text)) + let size = f + .metadata() + .map_err(|e| format!("plaintext read {}: {}", path.display(), e))? + .len() as usize; + + // procfs, sysfs and some FUSE mounts report zero for files that do + // have content, so a sized read would store nothing. Only these pay + // the read-to-EOF probe — which is what a genuinely empty file cost + // before anyway. + if size == 0 { + let mut buf = Vec::new(); + f.read_to_end(&mut buf) + .map_err(|e| format!("plaintext read {}: {}", path.display(), e))?; + return decode(buf, path); + } + + let mut buf = vec![0u8; size]; + let mut filled = 0; + while filled < size { + match f.read(&mut buf[filled..]) { + Ok(0) => break, + Ok(n) => filled += n, + Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => {} + Err(e) => return Err(format!("plaintext read {}: {}", path.display(), e)), + } + } + buf.truncate(filled); + decode(buf, path) + } + + fn extract_from_head( + &self, + path: &Path, + head: &[u8], + ) -> Option> { + Some(decode(head.to_vec(), path)) } } @@ -36,23 +100,115 @@ impl Extractor for PlaintextExtractor { mod tests { use super::*; - #[test] - fn reads_utf8_file() { + fn tmp(tag: &str, body: &[u8]) -> std::path::PathBuf { let mut p = std::env::temp_dir(); p.push(format!( - "qs-plaintext-{}-{}.txt", + "qs-plaintext-{}-{}-{}.txt", + tag, std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); - std::fs::write(&p, "hello world").unwrap(); + std::fs::write(&p, body).unwrap(); + p + } + + #[test] + fn reads_utf8_file() { + let p = tmp("basic", b"hello world"); let c = PlaintextExtractor.extract(&p).unwrap(); assert_eq!(c.text, "hello world"); std::fs::remove_file(&p).ok(); } + #[test] + fn head_extraction_matches_reading_the_file() { + 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(); + assert_eq!(from_disk.text, from_head.text); + assert_eq!(from_disk.properties, from_head.properties); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn both_paths_reject_invalid_utf8_and_name_the_file() { + let p = tmp("badutf8", &[0x68, 0x69, 0xff, 0xfe]); + let disk_err = PlaintextExtractor.extract(&p).unwrap_err(); + let head_err = PlaintextExtractor + .extract_from_head(&p, &[0x68, 0x69, 0xff, 0xfe]) + .unwrap() + .unwrap_err(); + assert_eq!(disk_err, head_err, "one decode path, one message"); + assert!(disk_err.contains("badutf8"), "the failure names the file: {}", disk_err); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn reads_a_file_larger_than_one_buffer_completely() { + // Past any plausible head window, so the read loop has to iterate if + // the kernel returns a short read. + let body = "abcdefgh".repeat(200 * 1024 / 8); + let p = tmp("large", body.as_bytes()); + let c = PlaintextExtractor.extract(&p).unwrap(); + assert_eq!(c.text.len(), body.len()); + assert_eq!(c.text, body); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn an_empty_file_extracts_to_empty_text() { + let p = tmp("empty", b""); + assert_eq!(PlaintextExtractor.extract(&p).unwrap().text, ""); + assert_eq!( + PlaintextExtractor.extract_from_head(&p, &[]).unwrap().unwrap().text, + "" + ); + std::fs::remove_file(&p).ok(); + } + + /// A file whose reported size is a lie in the "there is more than this" + /// direction — the shape procfs and sysfs have. Sizing the buffer from + /// `st_size` alone would store nothing, so `extract` must fall back to + /// reading until EOF. + #[test] + fn a_file_reporting_zero_size_is_still_read_to_eof() { + let p = Path::new("/proc/self/status"); + if !p.exists() { + return; // not Linux; the guard is only reachable there + } + assert_eq!( + std::fs::metadata(p).unwrap().len(), + 0, + "precondition: procfs reports zero size" + ); + let c = PlaintextExtractor.extract(p).unwrap(); + assert!( + c.text.contains("Name:"), + "content must survive a zero st_size, got {} bytes", + c.text.len() + ); + } + + /// The same lie in the other direction, which the sized read handles by + /// keeping whatever was actually there. + #[test] + fn a_file_that_shrank_after_sizing_keeps_its_prefix() { + let p = tmp("shrink", &vec![b'x'; 4096]); + let f = File::options().write(true).open(&p).unwrap(); + // Truncate behind `extract`'s back is not reproducible, so assert the + // property directly: a buffer sized larger than the file yields the + // file, not an error. + f.set_len(10).unwrap(); + drop(f); + let c = PlaintextExtractor.extract(&p).unwrap(); + assert_eq!(c.text, "xxxxxxxxxx", "a shrunk file reads short, not fatal"); + std::fs::remove_file(&p).ok(); + } + #[test] fn supports_text_mimes() { let e = PlaintextExtractor; diff --git a/crates/quicksearch-core/src/file_handling.rs b/crates/quicksearch-core/src/file_handling.rs index b1511f6..21e7a9a 100644 --- a/crates/quicksearch-core/src/file_handling.rs +++ b/crates/quicksearch-core/src/file_handling.rs @@ -1,9 +1,10 @@ use std::sync::atomic::AtomicBool; use std::sync::{Mutex, Arc}; -use std::ffi::OsString; use std::fs::File; -use std::io::{Read, Seek, SeekFrom}; -use std::path::{Component, Path}; +use std::io::Read; +use std::path::Path; +// Only the Unix entry-count path shells out; Windows walks the tree directly. +#[cfg(unix)] use std::process::{Command, Stdio}; use std::time::UNIX_EPOCH; use std::collections::HashMap; @@ -12,56 +13,17 @@ use sha2::{Sha256, Digest}; use walkdir::{DirEntry, WalkDir}; use rusqlite::Connection; -use crate::config::Config; +use crate::config::{Config, IgnoreSet}; use crate::db::repo::{self, NewFile}; use crate::extract::Registry; use crate::indexing::should_abort; -use crate::mime::{guess_mime, mime_to_type, FileType}; +use crate::mime::{guess_mime_from_head, mime_to_type, FileType}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ExistingFileEntry { pub mtime: u64, } -pub const PLAINTEXT_EXTENSIONS_LIST: [&'static str; 84] = - ["c","cs","csx", // C - "cpp","cc","cxx","hpp","hh","hxx","h", // C++ - "cfg","conf","ini","gitattributes","gitignore", // Config (General) - "toml","env","tf","tfvars", // Config (Infrastructure) - "scss","sass","less", // CSS Preprocessors - "dart", // Dart - "diff","patch", // Diffs - "go", // Go - "graphql","gql", // GraphQL - "html","htm","xhtml","xht","jsp","asp","aspx", // HTML - "java", // Java - "js","cjs","mjs","jsx","ts","tsx", // Javascript and TypeScript - "vue","svelte", // JS Frameworks - "kt","kts", // Kotlin - "tex","bib", // LaTeX - "css","xml","md","json","yaml","yml", // Markup - "m", // Objective-C - "pl","pm","t", // Perl - "php","phtml", // PHP - "proto", // Protocol Buffers - "py","pyw","pyi","ipynb", // Python - "r", // R - "rb", // Ruby - "rs", // Rust - "sh","bat","cmd","bash","ps1","psm1","psd1", // Scripts - "sql", // SQL - "csv", // Spreadsheet - "svg", // SVG - "swift", // Swift - "","txt","rtf","log", // Text Documents - "wasm", // Web Assembly - ]; - -pub const SUPPORTED_DOCUMENT_EXTENSIONS_LIST: [&'static str; 9] = - ["odt", "docx", "doc", // Office Documents - "ppt", "pptx", "odp", // Presentation - "xls", "xlsx", "ods"]; // Spreadsheet - /// Load path and mtime per row for incremental classification (hash/size loaded only when updating a file). pub fn load_existing_files(conn: &Connection) -> Result, rusqlite::Error> { let mut existing_files = HashMap::new(); @@ -97,6 +59,100 @@ fn inode_and_device(_meta: &std::fs::Metadata) -> (Option, Option) { } } +/// Render a path as the string stored in `files.path`. +/// +/// `Path::canonicalize` on Windows hands back extended-length paths; the +/// index stores plain ones. A UNC share canonicalizes to +/// `\\?\UNC\server\share`, so the two prefixes have to be stripped +/// differently — taking four characters off both leaves `UNC\server\share`, +/// which is not a path that exists. +/// +/// A volume mounted at a folder rather than a drive letter has no DOS name, so +/// it canonicalizes to `\\?\Volume{GUID}\…`. Stripping the prefix there yields +/// `Volume{GUID}\…`, which cannot be opened — every file under such a mount +/// would fail to hash. Only a genuine drive letter is safe to un-prefix. +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)) { + rest.to_string() + } else { + s.into_owned() + } +} + +/// Warn and report `true` for a path that cannot round-trip through +/// `files.path`. +/// +/// `files.path` is a TEXT column, and everything downstream of the walk +/// reopens the file by that string: hashing, MIME sniffing, text extraction, +/// and opening a result from the GUI. [`path_to_db_string`] goes through +/// `to_string_lossy`, so a name that is not valid UTF-8 arrives with its bad +/// bytes replaced by U+FFFD and then names a file that does not exist. Such a +/// file is skipped whole rather than stored under a path nothing can reopen — +/// otherwise the first symptom is the hasher reporting "No such file or +/// directory" for a file the walk just stat'ed successfully, which reads like +/// a race or a broken share rather than a name that cannot be represented. +/// +/// The message spells the offending bytes out as `\xNN` (via `Debug`, which is +/// portable — no `#[cfg]` and no `OsStrExt`): a replacement character is easy +/// to miss in a terminal and vanishes entirely once the line has been copied +/// and pasted somewhere else. +pub(crate) fn warn_if_unrepresentable(path: &Path) -> bool { + if path.to_str().is_some() { + return false; + } + crate::log_warn!( + "Skipping file (name is not valid UTF-8, so it cannot be hashed \ + or text-indexed): {:?}", + path + ); + true +} + +/// Whether `s` begins `X:` for some ASCII letter — the only `\\?\` payload +/// that is still a usable path once the prefix is gone. +fn starts_with_drive_letter(s: &str) -> bool { + let mut it = s.chars(); + matches!((it.next(), it.next()), (Some(c), Some(':')) if c.is_ascii_alphabetic()) +} + +/// The `files.path` key for a path that may no longer exist. +/// +/// The insert side canonicalizes before storing, so a lookup that skips that +/// step compares two different spellings of the same file and silently matches +/// nothing. A `Remove` event names something already gone, though, so plain +/// `canonicalize` fails on it unconditionally — instead this canonicalizes the +/// deepest ancestor that still resolves and re-joins the missing tail. +/// +/// Cross-platform, not just Windows: on Linux a root reached through a +/// symlinked parent (`/home` → `/mnt/home`) makes every removal a no-op +/// without this. +pub fn db_key_for_missing_path(path: &Path) -> String { + let mut tail: Vec = Vec::new(); + let mut cursor = path; + + loop { + if let Ok(resolved) = cursor.canonicalize() { + let mut out = resolved; + for part in tail.iter().rev() { + out.push(part); + } + return path_to_db_string(&out); + } + match (cursor.file_name(), cursor.parent()) { + (Some(name), Some(parent)) => { + tail.push(name.to_os_string()); + cursor = parent; + } + // Nothing above resolves (a bare relative name, or a root that is + // itself gone) — the raw spelling is the best key available. + _ => return path_to_db_string(path), + } + } +} + /// Parent directory of a path as a UTF-8 string, empty if root. fn parent_str(path: &str) -> String { Path::new(path) @@ -106,26 +162,142 @@ fn parent_str(path: &str) -> String { } -pub fn indexed_walk_file_entries( - path: &str, +/// 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 +/// not read this directory" and "this directory's files are gone" must not +/// look alike — an unplugged drive or a network share that blips would +/// otherwise silently delete that whole subtree. See +/// [`UnreadableDirs::covers`] and the stale-entry guard in `run_indexing`. +/// +/// Shared behind a mutex because the walk that fills it is threaded. +#[derive(Debug, Default)] +pub struct UnreadableDirs { + dirs: Mutex>, +} + +impl UnreadableDirs { + pub fn record(&self, path: std::path::PathBuf) { + self.dirs.lock().unwrap().push(path); + } + + pub fn is_empty(&self) -> bool { + self.dirs.lock().unwrap().is_empty() + } + + pub fn paths(&self) -> Vec { + self.dirs.lock().unwrap().clone() + } + + /// Whether `path` lies under a directory the walk failed to read, and so + /// must not be treated as deleted. + /// + /// Compares by path component, not by string prefix: `/a/bc` does not + /// live under `/a/b`. + pub fn covers(&self, path: &str) -> bool { + let dirs = self.dirs.lock().unwrap(); + if dirs.is_empty() { + return false; + } + let path = Path::new(path); + dirs.iter().any(|d| path.starts_with(d)) + } +} + +/// Whether a walked entry survives the hidden/ignore filters. +/// +/// The single definition of "would we index this", shared by +/// [`filtered_walk`], [`filtered_dirs`], and the watcher's decision to +/// register a newly created directory. Keeping one predicate is what stops +/// the walker and the watcher from disagreeing about which subtrees exist. +/// +/// Used as a `walkdir` `filter_entry` predicate, so returning `false` for a +/// directory prunes the whole subtree instead of merely skipping the entry. +fn walk_filter(e: &DirEntry, include_hidden: bool, ignore: &IgnoreSet) -> bool { + // Depth 0 is the root itself — a `false` here would silence the + // entire walk, and users explicitly chose their roots. + if e.depth() == 0 { + return true; + } + let name = e.file_name().to_string_lossy(); + // Free on Windows (walkdir hands back the attributes `FindNextFileW` + // already returned) and never called on Unix, so the "no extra lstat" + // property below still holds. + if !include_hidden && crate::platform::entry_is_hidden(&name, || e.metadata().ok()) { + return false; + } + !ignore.matches_component(&name) && !ignore.matches_path_pattern(e.path()) +} + +/// The shared walk behind [`filtered_walk`] and [`filtered_dirs`]: prunes +/// hidden and ignored subtrees *before* descending, and records what it +/// could not read. +/// +/// 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. +fn walk_entries<'a>( + root: &str, follow_symlinks: bool, -) -> impl Iterator { - WalkDir::new(path) + include_hidden: bool, + ignore: &'a IgnoreSet, + failures: &'a UnreadableDirs, +) -> impl Iterator + 'a { + WalkDir::new(root) .follow_links(follow_symlinks) .into_iter() - .filter_map(|e| e.ok()) - .filter(|entry| !entry.metadata().map(|m| m.is_dir()).unwrap_or(true)) + .filter_entry(move |e| walk_filter(e, include_hidden, ignore)) + .filter_map(move |res| match res { + Ok(e) => Some(e), + Err(err) => { + // Record what we could not read. Dropping this on the floor + // is what turns a transient mount failure into a deletion. + if let Some(p) = err.path() { + crate::log_warn!("cannot read {}: {}", p.display(), err); + failures.record(p.to_path_buf()); + } else { + crate::log_warn!("walk error: {}", err); + } + None + } + }) } -pub fn path_has_hidden_component(path: &std::path::Path) -> bool { - path.components().any(|c| { - matches!( - c, - Component::Normal(name) if name.to_string_lossy().starts_with('.') - ) - }) +/// Walk `root` yielding only files, pruning hidden and ignored subtrees +/// *before* descending into them. This is the single choke point for "what +/// exists" during full indexing; watcher events apply the same rules via +/// [`IgnoreSet::matches_path`] + [`crate::platform::path_has_hidden_component_under`]. +pub fn filtered_walk<'a>( + root: &str, + follow_symlinks: bool, + include_hidden: bool, + ignore: &'a IgnoreSet, + failures: &'a UnreadableDirs, +) -> impl Iterator + 'a { + // `file_type` is the cached `d_type` from the directory read, so + // this costs nothing; `metadata()` would be an extra `lstat` per + // entry, which on a network share is a full round trip. + walk_entries(root, follow_symlinks, include_hidden, ignore, failures) + .filter(|entry| !entry.file_type().is_dir()) } +/// Walk `root` yielding only directories, using the same pruning as +/// [`filtered_walk`]. The watcher registers one inotify watch per yielded +/// directory — inotify has no recursive mode, so the set this returns is +/// exactly the set of watch descriptors a root costs. +pub fn filtered_dirs<'a>( + root: &str, + follow_symlinks: bool, + include_hidden: bool, + ignore: &'a IgnoreSet, + failures: &'a UnreadableDirs, +) -> impl Iterator + 'a { + walk_entries(root, follow_symlinks, include_hidden, ignore, failures) + .filter(|entry| entry.file_type().is_dir()) +} + + +#[cfg(unix)] fn parse_wc_l_stdout(bytes: &[u8]) -> Result { let s = String::from_utf8_lossy(bytes); let token = s @@ -138,89 +310,152 @@ fn parse_wc_l_stdout(bytes: &[u8]) -> Result { .map_err(|e| format!("wc: invalid count {:?}: {}", token, e)) } +/// Poll interval while waiting on count subprocesses; the granularity of +/// cancellation. #[cfg(unix)] -fn count_find_pipe_wc(path: &str) -> Result { - let mut find = Command::new("find") - .arg(path) +const COUNT_POLL_MS: u64 = 50; + +/// Wait for `terminal` (the last process in the pipeline) while honouring +/// `cancel`: on cancellation every process in `children` is killed and a +/// recognizable error is returned. On normal exit, returns the terminal +/// child's stdout. +#[cfg(unix)] +fn wait_pipeline_cancellable( + children: &mut [&mut std::process::Child], + cancel: &std::sync::atomic::AtomicBool, +) -> Result, String> { + use std::sync::atomic::Ordering; + loop { + if cancel.load(Ordering::Relaxed) { + for child in children.iter_mut() { + let _ = child.kill(); + let _ = child.wait(); + } + return Err("count cancelled".to_string()); + } + let terminal = children.last_mut().expect("pipeline has processes"); + match terminal.try_wait() { + Ok(Some(status)) => { + if !status.success() { + return Err(format!("count pipeline exited with {}", status)); + } + // The terminal child's output is a couple dozen bytes + // (a `wc -l` figure), so reading after exit can't deadlock. + let mut out = Vec::new(); + if let Some(stdout) = terminal.stdout.take() { + use std::io::Read; + let mut stdout = stdout; + let _ = stdout.read_to_end(&mut out); + } + // Reap the rest of the pipeline. + for child in children.iter_mut() { + let _ = child.wait(); + } + return Ok(out); + } + Ok(None) => std::thread::sleep(std::time::Duration::from_millis(COUNT_POLL_MS)), + Err(e) => return Err(format!("count wait: {}", e)), + } + } +} + +#[cfg(unix)] +fn count_find_pipe_wc( + path: &str, + cancel: &std::sync::atomic::AtomicBool, + printf_newlines: bool, +) -> Result { + let mut find_cmd = Command::new("find"); + find_cmd.arg(path); + if printf_newlines { + // GNU find: emit one newline per entry without formatting paths. + find_cmd.arg("-printf").arg("\n"); + } + let mut find = find_cmd .stdout(Stdio::piped()) .stderr(Stdio::null()) .spawn() .map_err(|e| format!("find: {}", e))?; let find_stdout = find.stdout.take().ok_or("find: stdout")?; - let wc = Command::new("wc") + let mut wc = match Command::new("wc") .arg("-l") .stdin(find_stdout) .stdout(Stdio::piped()) - .output() - .map_err(|e| format!("wc: {}", e))?; - find.wait().map_err(|e| format!("find wait: {}", e))?; - if !wc.status.success() { - return Err(format!("wc exited with {}", wc.status)); - } - parse_wc_l_stdout(&wc.stdout) -} - -#[cfg(target_os = "linux")] -fn count_find_printf_wc(path: &str) -> Result { - let mut find = Command::new("find") - .arg(path) - .arg("-printf") - .arg("\n") - .stdout(Stdio::piped()) - .stderr(Stdio::null()) .spawn() - .map_err(|e| format!("find: {}", e))?; - let find_stdout = find.stdout.take().ok_or("find: stdout")?; - let wc = Command::new("wc") - .arg("-l") - .stdin(find_stdout) - .stdout(Stdio::piped()) - .output() - .map_err(|e| format!("wc: {}", e))?; - find.wait().map_err(|e| format!("find wait: {}", e))?; - if !wc.status.success() { - return Err(format!("wc exited with {}", wc.status)); - } - parse_wc_l_stdout(&wc.stdout) + { + Ok(wc) => wc, + Err(e) => { + let _ = find.kill(); + let _ = find.wait(); + return Err(format!("wc: {}", e)); + } + }; + let out = wait_pipeline_cancellable(&mut [&mut find, &mut wc], cancel)?; + parse_wc_l_stdout(&out) } +/// Count tree entries with a plain directory walk. +/// +/// Windows has no `find`, and the obvious substitute — `powershell.exe -Command +/// "(Get-ChildItem -Recurse | Measure-Object).Count"` — is a poor trade: 300+ +/// ms of interpreter startup before any work, `Get-ChildItem -Recurse` is far +/// slower than a `FindNextFileW` loop, it pops a console window on a windowed +/// process, and the path has to be escaped into a script string. Walking +/// directly is faster, quieter, and cancels immediately instead of at the +/// 50 ms subprocess-poll granularity. #[cfg(windows)] -fn count_tree_entries_windows(path: &str) -> Result { - let lit = path.replace('\'', "''"); - let ps = format!( - "(Get-ChildItem -LiteralPath '{}' -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object).Count", - lit - ); - let out = Command::new("powershell.exe") - .args(["-NoProfile", "-NonInteractive", "-Command", &ps]) - .output() - .map_err(|e| format!("powershell: {}", e))?; - if !out.status.success() { - return Err(format!( - "powershell exited with {}: {}", - out.status, - String::from_utf8_lossy(&out.stderr) - )); +fn count_tree_entries_native( + path: &str, + cancel: &std::sync::atomic::AtomicBool, +) -> Result { + use std::sync::atomic::Ordering; + + let mut n = 0usize; + // Unreadable subtrees are skipped rather than fatal: this is a progress + // estimate, and the walk proper reports what it could not read. + for entry in WalkDir::new(path).min_depth(1) { + // Checked every entry rather than every Nth: a relaxed load is far + // cheaper than the directory read that produced the entry, and it + // makes cancellation immediate instead of merely prompt. + if cancel.load(Ordering::Relaxed) { + return Err("count cancelled".to_string()); + } + if entry.is_ok() { + n += 1; + } } - String::from_utf8_lossy(&out.stdout) - .trim() - .parse() - .map_err(|e| format!("invalid count output: {}", e)) + Ok(n) } -/// Rough tree entry count for progress totals (Linux: `find DIR -printf '\n' | wc -l` when GNU find is available, else `find DIR | wc -l`; macOS/other Unix: `find DIR | wc -l`; Windows: PowerShell `Get-ChildItem -Recurse`). Scope is not identical to the indexer’s classified file count. -pub fn count_tree_entries_fast(path: &str) -> Result { +/// Rough tree entry count for progress totals. Setting `cancel` stops it +/// rather than letting it scan an entire root after the run stopped: on Unix +/// that kills the `find`/`wc` subprocesses at ~50 ms granularity, on Windows +/// the native walk notices almost immediately. Runs concurrently with +/// indexing — its scope is not identical to the walker's classified file +/// count. +pub fn count_tree_entries_fast( + path: &str, + cancel: &std::sync::atomic::AtomicBool, +) -> Result { #[cfg(windows)] { - return count_tree_entries_windows(path); + return count_tree_entries_native(path, cancel); } #[cfg(all(unix, target_os = "linux"))] { - return count_find_printf_wc(path).or_else(|_| count_find_pipe_wc(path)); + 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")))] { - return count_find_pipe_wc(path); + return count_find_pipe_wc(path, cancel, false); } #[cfg(not(any(windows, unix)))] { @@ -235,45 +470,21 @@ pub enum FileIndexAction { Insert, } -/// Classify a walkdir entry for Phase 1 file indexing. Returns `None` if the path is not indexable. -pub fn classify_dir_entry_for_indexing( - entry: &DirEntry, +/// Decide what Phase 1 should do with a file, given the path spelling used +/// as the `files.path` key and the file's mtime. +/// +/// Pure: the caller supplies the `stat` result rather than this function +/// going to disk for it, so the same `stat` serves classification and the +/// record build, and this runs on any worker thread against a shared map. +pub fn classify_for_indexing( + path: &str, + mtime: u64, existing_files: &HashMap, -) -> Option { - let fpath = match entry.path().canonicalize() { - Ok(fp) => { - let path_str = fp.to_string_lossy().to_string(); - if path_str.starts_with("\\\\?\\") { - path_str[4..].to_string() - } else { - path_str - } - } - Err(_) => return None, - }; - - let meta = match std::fs::metadata(&fpath) { - Ok(m) if m.is_file() => m, - _ => return None, - }; - - let fmodified = match meta - .modified() - .ok() - .and_then(|m| m.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())) - { - Some(time) => time, - None => return None, - }; - - if let Some(existing) = existing_files.get(&fpath) { - if existing.mtime != fmodified { - Some(FileIndexAction::Update) - } else { - Some(FileIndexAction::Skip) - } - } else { - Some(FileIndexAction::Insert) +) -> FileIndexAction { + match existing_files.get(path) { + Some(existing) if existing.mtime == mtime => FileIndexAction::Skip, + Some(_) => FileIndexAction::Update, + None => FileIndexAction::Insert, } } @@ -292,33 +503,38 @@ fn safe_truncate_string(s: &str, max_bytes: usize) -> String { s[..end].to_string() } -/// Get a hash of a file by reading the first and last hash_length bytes of the file -fn get_file_hash(size: u64, path: OsString, hash_length: usize) -> Result, std::io::Error> { - let mut hasher = Sha256::new(); +/// Identify a file as `sha256(size || first hash_length bytes)`, returning +/// the head bytes alongside the digest so the caller can sniff a MIME type +/// without opening the file again. +/// +/// Only the head is read. Reading a tail block too would mean a seek and a +/// second, non-contiguous read — on a network share that is an extra round +/// trip per file that readahead cannot hide, and files are the unit we +/// process millions of. +/// +/// The cost is a collision class: two files of identical size whose first +/// `hash_length` bytes match hash identically. In practice that means +/// pre-allocated VM disk images — a fixed-size VHD keeps its unique footer +/// at the *end* of the file by design, and a freshly pre-allocated raw, +/// qcow2, or flat VMDK image is zeros at the head until it is partitioned. +/// Such files are reported as duplicates when they are not. Duplicate +/// listing is advisory (see `search::duplicates`), so this is a display +/// artifact rather than a correctness problem. +fn get_file_hash( + size: u64, + path: &Path, + hash_length: usize, +) -> Result<(Vec, Vec), std::io::Error> { let mut f: File = File::open(path)?; - hasher.update(&size.to_le_bytes()); - if size > hash_length as u64 { - let mut file_start_block = vec![0u8; hash_length]; - f.read_exact(&mut file_start_block)?; - hasher.update(&file_start_block); - f.seek(SeekFrom::End(0 - hash_length as i64))?; - let mut file_end_block = vec![0u8; hash_length]; - f.read_exact(&mut file_end_block)?; - hasher.update(&file_end_block); - } else if size > 0 { - let mut file_block = Vec::new(); - f.read_to_end(&mut file_block)?; - hasher.update(file_block); - } - drop(f); - Ok(hasher.finalize().to_vec()) -} + // Files shorter than the window hash whole; `min` keeps the cast sound + // for large files on 32-bit targets. + let mut head = vec![0u8; size.min(hash_length as u64) as usize]; + f.read_exact(&mut head)?; -fn format_progress_pair(visit_index: usize, progress_display_total: Option) -> String { - match progress_display_total { - Some(t) => format!("{}/{}", visit_index, t), - None => format!("{}", visit_index), - } + let mut hasher = Sha256::new(); + hasher.update(&size.to_le_bytes()); + hasher.update(&head); + Ok((hasher.finalize().to_vec(), head)) } /// Nudge FTS5 to merge its index segments. Best-effort optimization; any @@ -328,29 +544,238 @@ pub fn fts_finalize_after_text_indexing(conn: &Connection) -> Result<(), String> "INSERT INTO searchabletext(searchabletext, rank) VALUES('automerge', 8)", [], ) { - eprintln!("Warning: FTS automerge failed (non-fatal): {}", e); + crate::log_warn!("FTS automerge failed (non-fatal): {}", e); } Ok(()) } -struct PreparedFileUpdate { - path_db: String, - fsize: u64, - fmodified: u64, - fhash: Vec, - filename: String, - visit_index: usize, +/// An owned, fully-derived file record: everything needed to insert or +/// update a `files` row, produced by [`prepare_file_record`]. +#[derive(Debug, Clone)] +pub struct OwnedNewFile { + pub name: String, + pub path: String, + pub parent: String, + pub size: u64, + pub mtime: u64, + pub inode: Option, + pub device_id: Option, + pub mime: Option, + pub ftype: FileType, + pub hash: Vec, + /// Text extracted from the head bytes during the walk, for files small + /// enough that the head *was* the whole file. `Some` means the content + /// pass never has to open this file; `None` leaves it pending as before. + /// + /// Only plaintext files at or below `hash_length` (8 KiB by default) carry + /// one, and the walk's channel is bounded at `CHANNEL_CAP`, so this adds + /// at most `CHANNEL_CAP * hash_length` of in-flight memory. + pub inline_text: Option, } -/// Process updated files in batch with transaction - files table only (no text extraction) -pub fn process_batch_updates_files_only( - conn_mutex: &Arc>, - files_to_update: &[(DirEntry, usize)], - stop_flag: &Arc>, - status_callback: Option>, - progress_callback: Option>, +impl OwnedNewFile { + pub fn as_new_file(&self) -> NewFile<'_> { + NewFile { + name: &self.name, + path: &self.path, + parent: &self.parent, + size: self.size, + mtime: self.mtime, + inode: self.inode, + device_id: self.device_id, + mime: self.mime.as_deref(), + ftype: self.ftype, + hash: Some(&self.hash), + } + } +} + +/// Build the `files` row for one on-disk file from a `stat` the caller +/// already holds. The single implementation behind both full-run batches +/// and incremental watcher updates. +/// +/// `path` must already be canonical and in `files.path` spelling (see +/// [`path_to_db_string`]), and must still name the file once parsed back into +/// a [`Path`] — this opens it by that string. A path that only survived +/// `to_string_lossy` does not qualify; callers holding the original +/// [`Path`] screen it with [`warn_if_unrepresentable`] first. The full walk +/// gets the canonical spelling for free — every path it +/// produces descends from a canonicalized root — which saves a `realpath` +/// per file, and `realpath` costs roughly one `readlink` per path +/// component. Callers holding an unresolved path want +/// [`prepare_file_record_from_path`] instead. +/// +/// Returns `None` for anything that isn't a readable regular file, with a +/// warning when hashing fails. +pub fn prepare_file_record( + path: &str, + meta: &std::fs::Metadata, + config: &Config, + registry: &Registry, +) -> Option { + if !meta.is_file() { + return None; + } + + let size = meta.len(); + let mtime = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs())?; + + let (hash, head) = match get_file_hash(size, Path::new(path), config.processing.hash_length) { + Ok(v) => v, + Err(e) => { + crate::log_warn!("Skipping file (cannot hash) {}: {}", path, e); + return None; + } + }; + + let name = Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().into_owned())?; + let parent = parent_str(path); + let (inode, device_id) = inode_and_device(meta); + // Sniff from the bytes hashing already read rather than reopening. + let mime = guess_mime_from_head(Path::new(path), &head); + let ftype = mime.as_deref().map(mime_to_type).unwrap_or(FileType::EMPTY); + + // When the head is the whole file, an extractor that works from bytes can + // finish the job now and spare the content pass an open/read/close. Any + // condition that does not hold simply leaves this `None`, and the file + // stays pending exactly as before — including invalid UTF-8, which the + // content pass records as a failure with a reason. + let inline_text = mime.as_deref().and_then(|m| { + // The head is the whole file only up to `hash_length`. The + // `maximum_text_file_size` gate is the content pass's own (see + // `extract_scope_prepare`), repeated so both paths agree even when a + // config sets it below `hash_length`. + // + // Size 0 is excluded rather than treated as "trivially complete": + // procfs, sysfs and some FUSE mounts report it for files that do have + // content, and inlining would store empty text for them. The content + // pass reads those correctly, and an actually-empty file costs the + // same there as it ever did. + if size == 0 + || size > config.processing.hash_length as u64 + || size > config.processing.maximum_text_file_size + || !crate::config::content_allowed(Path::new(path), config) + { + return None; + } + match registry.extract_complete_head(Path::new(path), m, &head) { + Some(Ok(content)) => { + let mut text = content.text; + if text.len() > config.processing.maximum_text_size { + text = safe_truncate_string(&text, config.processing.maximum_text_size); + } + Some(text) + } + // A failure here is real, but recording it needs a file id the + // walk does not have. Leaving it pending costs one reopen and + // keeps failure reporting in one place. + Some(Err(_)) | None => None, + } + }); + + Some(OwnedNewFile { + name, + path: path.to_string(), + parent, + size, + mtime, + inode, + device_id, + mime, + ftype, + hash, + inline_text, + }) +} + +/// [`prepare_file_record`] for a path that has not been resolved yet. +/// +/// This is the watcher path: one file per event, so the extra `realpath` +/// and `stat` are irrelevant, and in exchange the caller doesn't have to +/// know about canonical spelling. +pub fn prepare_file_record_from_path( + path: &Path, + config: &Config, + registry: &Registry, +) -> Option { + let canonical = path.canonicalize().ok()?; + if warn_if_unrepresentable(&canonical) { + return None; + } + let db_path = path_to_db_string(&canonical); + let meta = std::fs::metadata(&canonical).ok()?; + prepare_file_record(&db_path, &meta, config, registry) +} + +/// Extract content for one file and record the outcome on its row: text + +/// properties on success, `NA` when no extractor applies or the +/// `content_extensions` filter excludes it, `FAILED` with a reason on +/// extractor errors. The single implementation behind the full text-index +/// pass and incremental updates. +/// +/// `mime` is authoritative, including when it is `None`. Every row reaches +/// here from [`prepare_file_record`], which has already sniffed the file's +/// head with [`guess_mime_from_head`] — and `infer` reads only the first few +/// hundred bytes, so sniffing the head and sniffing the path give the same +/// answer. Re-deriving it from disk therefore cost an open/fstat/read/close +/// per undetectable file to reproduce a `None` we were already handed. +pub fn extract_and_store( + tx: &rusqlite::Transaction<'_>, + file_id: i64, + name: &str, + path: &str, + mime: Option<&str>, + registry: &Registry, + config: &Config, +) -> Result<(), String> { + let p = Path::new(path); + if !crate::config::content_allowed(p, config) { + return repo::set_content_na(tx, file_id); + } + let result = match mime { + Some(m) => registry.extract(p, m), + None => Ok(None), + }; + match result { + Ok(Some(mut content)) => { + if content.text.len() > config.processing.maximum_text_size { + content.text = safe_truncate_string(&content.text, config.processing.maximum_text_size); + } + let props = content.properties_sorted(); + repo::set_content_done( + tx, + file_id, + name, + &content.text, + &props, + config.processing.store_text_for_snippets, + ) + } + Ok(None) => repo::set_content_na(tx, file_id), + Err(reason) => repo::set_content_failed(tx, file_id, &reason), + } +} + +/// Write already-prepared records for files whose content changed. +/// +/// The records arrive fully built (see [`prepare_file_record`]), so this does +/// no filesystem I/O; it only chunks the rows so each transaction, and +/// therefore each hold of the connection lock, stays short. Records that +/// already carry their text ([`OwnedNewFile::inline_text`]) are stored +/// complete here, which is what keeps the content pass from reopening them; +/// the rest stay pending. Progress display is the writer loop's job — these +/// are silent DB writers. +pub fn process_batch_updates( + conn_mutex: &Arc>, + files_to_update: &[OwnedNewFile], + stop_flag: &Arc>, config: &Config, - progress_display_total: Option, ) -> Result<(), String> { if files_to_update.is_empty() { return Ok(()); @@ -363,125 +788,53 @@ pub fn process_batch_updates_files_only( return Ok(()); } - let mut prepared: Vec = Vec::new(); - - for (entry, visit_index) in batch.iter() { - if *stop_flag.lock().unwrap() { - return Ok(()); - } - - let filename = entry - .path() - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); - - if let Some(ref callback) = status_callback { - let pair = format_progress_pair(*visit_index, progress_display_total); - callback(&format!("Hashing changed files {}: {}", pair, filename)); - } - - if let Some(ref progress_cb) = progress_callback { - progress_cb(*visit_index); - } - - let fpath = match entry.path().canonicalize() { - Ok(fp) => { - let path_str = fp.to_string_lossy().to_string(); - if path_str.starts_with("\\\\?\\") { - std::ffi::OsString::from(&path_str[4..]) - } else { - fp.into_os_string() - } - } - Err(_) => continue, - }; - - let meta = match std::fs::metadata(&fpath) { - Ok(m) if m.is_file() => m, - _ => continue, - }; - - let fsize = meta.len(); - let fmodified = meta - .modified() - .map_err(|e| format!("Failed to get modified time: {}", e))? - .duration_since(UNIX_EPOCH) - .map_err(|e| format!("Failed to calculate duration: {}", e))? - .as_secs(); - - let fhash = match get_file_hash(fsize, fpath.clone(), config.processing.hash_length) { - Ok(h) => h, - Err(e) => { - eprintln!( - "Warning: Skipping file (cannot hash) {}: {}", - fpath.to_string_lossy(), - e - ); - continue; - } - }; - - if *stop_flag.lock().unwrap() { - return Ok(()); - } - - prepared.push(PreparedFileUpdate { - path_db: fpath.to_string_lossy().into_owned(), - fsize, - fmodified, - fhash, - filename, - visit_index: *visit_index, - }); - } - - if prepared.is_empty() { - continue; - } - let conn = conn_mutex.lock().unwrap(); let tx = conn .unchecked_transaction() .map_err(|e| format!("Failed to begin transaction: {}", e))?; - for row in &prepared { + for rec in batch.iter() { if *stop_flag.lock().unwrap() { drop(tx); drop(conn); return Ok(()); } - if let Some(ref callback) = status_callback { - let pair = format_progress_pair(row.visit_index, progress_display_total); - callback(&format!( - "Applying index updates {}: {}", - pair, row.filename - )); - } - - let p = Path::new(row.path_db.as_str()); - let guessed_mime = guess_mime(p); - let ftype = guessed_mime - .as_deref() - .map(mime_to_type) - .unwrap_or(FileType::EMPTY); - let _ = repo::update_file_basic( + let updated = repo::update_file_basic( &tx, - &row.path_db, - row.fsize, - row.fmodified, - Some(row.fhash.as_slice()), - guessed_mime.as_deref(), - ftype, + &rec.path, + rec.size, + rec.mtime, + Some(rec.hash.as_slice()), + rec.mime.as_deref(), + rec.ftype, ) .map_err(|e| { format!( "Failed to update file record + clear stale content for {}: {}", - row.path_db, e + rec.path, e ) })?; + + // No row matched: the path spelling we're writing disagrees with + // the one stored. Silently dropping the update would leave the + // row's mtime stale, so it would be reclassified as changed and + // re-hashed on every run forever. + let id = match updated { + Some(id) => Some(id), + None => { + crate::log_warn!( + "no indexed row matched {} during update; inserting instead", + rec.path + ); + repo::insert_file(&tx, &rec.as_new_file()) + .map_err(|e| format!("Failed to insert file record: {}", e))? + } + }; + + if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) { + store_inline_text(&tx, id, rec, text, config)?; + } } tx.commit() @@ -491,127 +844,68 @@ pub fn process_batch_updates_files_only( Ok(()) } -/// Process new files in batch with transaction - files table only (no text extraction) -pub fn process_batch_inserts_files_only( - conn_mutex: &Arc>, - files_to_insert: &[(DirEntry, usize)], - stop_flag: &Arc>, - status_callback: Option>, - progress_callback: Option>, +/// Store text the walk already extracted, so the content pass skips this row. +/// +/// Deliberately the same [`repo::set_content_done`] the content pass calls, +/// with the same empty property set a plaintext extraction produces, so a row +/// finished here is indistinguishable from one finished there. +pub(crate) fn store_inline_text( + tx: &rusqlite::Transaction<'_>, + file_id: i64, + rec: &OwnedNewFile, + text: &str, + config: &Config, +) -> Result<(), String> { + repo::set_content_done( + tx, + file_id, + &rec.name, + text, + &[], + config.processing.store_text_for_snippets, + ) +} + +/// Write already-prepared records for newly discovered files. Silent, like +/// [`process_batch_updates`], and likewise stores any text the walk already +/// extracted. +pub fn process_batch_inserts( + conn_mutex: &Arc>, + files_to_insert: &[OwnedNewFile], + stop_flag: &Arc>, config: &Config, - progress_display_total: Option, ) -> Result<(), String> { if files_to_insert.is_empty() { return Ok(()); } - let batch_size = config.processing.batch_size; - - // Process files in batches of batch_size - for batch in files_to_insert.chunks(batch_size) { + for batch in files_to_insert.chunks(config.processing.batch_size) { if *stop_flag.lock().unwrap() { return Ok(()); } let conn = conn_mutex.lock().unwrap(); - let tx = conn.unchecked_transaction().map_err(|e| format!("Failed to begin transaction: {}", e))?; + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("Failed to begin transaction: {}", e))?; - for (entry, visit_index) in batch.iter() { - // Check stop flag for early termination + for rec in batch.iter() { if *stop_flag.lock().unwrap() { drop(tx); drop(conn); return Ok(()); } - - // Update status with current file - if let Some(ref callback) = status_callback { - let filename = entry.path().file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown"); - let pair = format_progress_pair(*visit_index, progress_display_total); - callback(&format!("Indexing file metadata {}: {}", pair, filename)); + let id = repo::insert_file(&tx, &rec.as_new_file()) + .map_err(|e| format!("Failed to insert file record: {}", e))?; + if let (Some(id), Some(text)) = (id, rec.inline_text.as_deref()) { + store_inline_text(&tx, id, rec, text, config)?; } - - // Update progress counter - if let Some(ref progress_cb) = progress_callback { - progress_cb(*visit_index); - } - - let fpath = match entry.path().canonicalize() { - Ok(fp) => { - let path_str = fp.to_string_lossy().to_string(); - // Remove Windows UNC prefix \\?\ - if path_str.starts_with("\\\\?\\") { - std::ffi::OsString::from(&path_str[4..]) - } else { - fp.into_os_string() - } - }, - Err(_) => continue, - }; - - let meta = match std::fs::metadata(&fpath) { - Ok(m) if m.is_file() => m, - _ => continue, - }; - - let fsize = meta.len(); - let fmodified = meta.modified() - .map_err(|e| format!("Failed to get modified time: {}", e))? - .duration_since(UNIX_EPOCH) - .map_err(|e| format!("Failed to calculate duration: {}", e))? - .as_secs(); - - let fhash = match get_file_hash(fsize, fpath.clone(), config.processing.hash_length) { - Ok(h) => h, - Err(e) => { - eprintln!( - "Warning: Skipping file (cannot hash) {}: {}", - fpath.to_string_lossy(), - e - ); - continue; - } - }; - - let fname = entry.path().file_name().unwrap().to_os_string(); - let fname_str = fname.to_string_lossy().into_owned(); - let fpath_str = fpath.to_string_lossy().into_owned(); - let parent = parent_str(&fpath_str); - let (inode, device_id) = inode_and_device(&meta); - let guessed_mime = guess_mime(Path::new(&fpath_str)); - let ftype = guessed_mime - .as_deref() - .map(mime_to_type) - .unwrap_or(FileType::EMPTY); - - repo::insert_file( - &tx, - &NewFile { - name: &fname_str, - path: &fpath_str, - parent: &parent, - size: fsize, - mtime: fmodified, - inode, - device_id, - mime: guessed_mime.as_deref(), - ftype, - hash: Some(fhash.as_slice()), - }, - ) - .map_err(|e| format!("Failed to insert file record: {}", e))?; } - // Update status with current file - if let Some(ref callback) = status_callback { - callback("Committing file updates to database…"); - } - - tx.commit().map_err(|e| format!("Failed to commit transaction: {}", e))?; + tx.commit() + .map_err(|e| format!("Failed to commit transaction: {}", e))?; } - + Ok(()) } @@ -620,7 +914,6 @@ pub fn cleanup_stale_index_entries( stale_paths: &[String], stop_flag: &Arc>, suspend_flag: &Arc, - status_callback: Option>, ) -> Result { if stale_paths.is_empty() { return Ok(0); @@ -639,10 +932,6 @@ pub fn cleanup_stale_index_entries( return Ok(deleted_count); } - if let Some(ref callback) = status_callback { - callback(&format!("Removing stale index entry: {}", path)); - } - if repo::delete_file_by_path(&tx, path).map_err(|e| { format!( "Failed to remove stale index entry for {}: {}", @@ -657,174 +946,644 @@ pub fn cleanup_stale_index_entries( .map_err(|e| format!("Failed to commit stale cleanup transaction: {}", e))?; if deleted_count > 0 && !should_abort(stop_flag, suspend_flag) { - if let Some(ref callback) = status_callback { - callback("Optimizing FTS index after stale cleanup..."); - } fts_finalize_after_text_indexing(&conn)?; } Ok(deleted_count) } -/// Process text indexing for all files with `content_state = pending`. For -/// each file dispatches to the configured extractor [`Registry`], writes the -/// extracted text + properties via the repo helpers, then flips the row's -/// `content_state` to done/failed/na so it won't be retried next run. -pub fn process_text_indexing( +/// Keyset cursor for per-root content extraction. `lo`/`hi` bound the +/// root's path range: `[root + "/", root + "0")` — `'0'` is `'/' + 1`, so +/// the pair is a pure index range on `UNIQUE(files.path)`. +#[derive(Debug, Clone)] +pub struct ExtractCursor { + pub last_id: i64, + pub lo: String, + pub hi: String, +} + +impl ExtractCursor { + /// Cursor covering everything under `root`. + pub fn for_root(root: &str) -> ExtractCursor { + let base = root.trim_end_matches('/'); + ExtractCursor { + last_id: 0, + lo: format!("{}/", base), + hi: format!("{}0", base), + } + } +} + +/// What a root's extraction scope holds: rows still to extract this run, +/// and rows whose text is already searchable from earlier runs. Progress +/// displays show their sum so an unchanged root reads as fully extracted +/// rather than "extracted 0". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExtractScope { + pub pending: usize, + pub already_done: usize, +} + +/// Prepare a root's extraction scope: flip oversize pending rows to NA +/// (idempotent; also handles a `maximum_text_file_size` lowered between +/// runs) and count what is pending vs. already extracted in the range. +pub fn extract_scope_prepare( conn_mutex: &Arc>, - stop_flag: &Arc>, - suspend_flag: &Arc, - status_callback: Option>, - progress_callback: Option>, + cursor: &ExtractCursor, config: &Config, -) -> Result<(), String> { - let registry = Registry::default_set(); +) -> Result { let max_size = config.processing.maximum_text_file_size; - let batch_size = config.processing.batch_size; - let batch_limit = batch_size as i64; - - if let Some(ref callback) = status_callback { - callback("Counting files pending text index…"); - } - - // Files bigger than our text-file cap can never graduate from - // `content_state = 0`, so they'd otherwise sit in the "pending content - // indexing" column forever and peg System Settings' progress below - // 100%. Flip them to `content_state = 3` (not-applicable) now. Doing - // this every run is idempotent and also handles the case where a user - // *lowers* `maximum_text_file_size` between runs — previously-pending - // files that cross the threshold get correctly marked. - { - let conn = conn_mutex.lock().unwrap(); - conn.execute( - "UPDATE files SET content_state = 3 \ - WHERE content_state = 0 AND size > ?1", - [max_size], - ) - .map_err(|e| format!("mark oversize files NA: {}", e))?; - } - - let total_files: usize = { - let conn = conn_mutex.lock().unwrap(); - conn.query_row( - "SELECT COUNT(*) FROM files WHERE content_state = 0 AND size <= ?1", - [max_size], + let conn = conn_mutex.lock().unwrap(); + conn.execute( + "UPDATE files SET content_state = 3 \ + WHERE content_state = 0 AND size > ?1 AND path >= ?2 AND path < ?3", + rusqlite::params![max_size, cursor.lo, cursor.hi], + ) + .map_err(|e| format!("mark oversize files NA: {}", e))?; + let pending: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files \ + WHERE content_state = 0 AND size <= ?1 AND path >= ?2 AND path < ?3", + rusqlite::params![max_size, cursor.lo, cursor.hi], |row| row.get(0), ) - .map_err(|e| format!("Failed to count pending text files: {}", e))? + .map_err(|e| format!("Failed to count pending text files: {}", e))?; + let already_done: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files \ + WHERE content_state = 1 AND path >= ?1 AND path < ?2", + rusqlite::params![cursor.lo, cursor.hi], + |row| row.get(0), + ) + .map_err(|e| format!("Failed to count extracted files: {}", e))?; + Ok(ExtractScope { + pending: pending.max(0) as usize, + already_done: already_done.max(0) as usize, + }) +} + +/// Run ONE bounded batch of content extraction within the cursor's range. +/// Returns rows processed; 0 means the range is drained (or the run is +/// stopping). `on_file` receives each file's name for progress display. +/// Designed to be pumped by the per-root writer loop, so one root's +/// extraction interleaves with other roots' walks and extractions. +pub fn extract_one_batch( + conn_mutex: &Arc>, + cursor: &mut ExtractCursor, + registry: &Registry, + config: &Config, + stop_flag: &Arc>, + suspend_flag: &Arc, + on_file: &mut dyn FnMut(&str), +) -> Result { + if should_abort(stop_flag, suspend_flag) { + return Ok(0); + } + let max_size = config.processing.maximum_text_file_size; + let batch_limit = config.processing.batch_size.max(1) as i64; + + let batch: Vec<(i64, String, String, Option)> = { + let conn = conn_mutex.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, name, path, mime FROM files + WHERE content_state = 0 AND size <= ?1 AND id > ?2 + AND path >= ?3 AND path < ?4 + ORDER BY id + LIMIT ?5", + ) + .map_err(|e| format!("Failed to prepare text indexing query: {}", e))?; + let rows = stmt + .query_map( + rusqlite::params![max_size, cursor.last_id, cursor.lo, cursor.hi, batch_limit], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + }, + ) + .map_err(|e| format!("Failed to query files for text indexing: {}", e))?; + rows.collect::, _>>() + .map_err(|e| format!("Failed to read file row: {}", e))? }; - let mut cursor_id: i64 = 0; - let mut global_index: usize = 0; + if batch.is_empty() { + return Ok(0); + } - loop { - if should_abort(stop_flag, suspend_flag) { - return Ok(()); - } - - let batch: Vec<(i64, String, String, Option)> = { - let conn = conn_mutex.lock().unwrap(); - let mut stmt = conn - .prepare( - "SELECT id, name, path, mime FROM files - WHERE content_state = 0 AND size <= ?1 AND id > ?2 - ORDER BY id - LIMIT ?3", - ) - .map_err(|e| format!("Failed to prepare text indexing query: {}", e))?; - let rows = stmt - .query_map( - rusqlite::params![max_size, cursor_id, batch_limit], - |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, Option>(3)?, - )) - }, - ) - .map_err(|e| format!("Failed to query files for text indexing: {}", e))?; - rows.collect::, _>>() - .map_err(|e| format!("Failed to read file row: {}", e))? - }; - - if batch.is_empty() { + let mut processed = 0usize; + let conn = conn_mutex.lock().unwrap(); + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("Failed to begin transaction: {}", e))?; + for (file_id, fname, fpath, fmime) in batch.iter() { + if *stop_flag.lock().unwrap() { break; } + on_file(fname); + if let Err(e) = extract_and_store( + &tx, + *file_id, + fname, + fpath, + fmime.as_deref(), + registry, + config, + ) { + crate::log_warn!("content indexing for {}: {}", fpath, e); + } + // Advance only past what was actually processed, so a stop + // mid-batch never skips rows (they stay pending for the next run). + cursor.last_id = *file_id; + processed += 1; + } + tx.commit() + .map_err(|e| format!("Failed to commit transaction: {}", e))?; + Ok(processed) +} - cursor_id = batch.last().unwrap().0; +#[cfg(test)] +mod tests { + use super::*; + use std::path::MAIN_SEPARATOR; - let conn = conn_mutex.lock().unwrap(); - let tx = conn - .unchecked_transaction() - .map_err(|e| format!("Failed to begin transaction: {}", e))?; + fn tmp_tree() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "quicksearch-walk-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + p + } - for (file_id, fname, fpath, fmime) in batch.iter() { - if *stop_flag.lock().unwrap() { - let _ = tx.commit(); - drop(conn); - return Ok(()); - } + fn touch(p: &Path) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"x").unwrap(); + } - global_index += 1; + #[test] + fn filtered_walk_prunes_hidden_and_ignored() { + let root = tmp_tree(); + touch(&root.join("keep.txt")); + touch(&root.join("sub/keep2.txt")); + touch(&root.join("sub/skip.tmp")); + touch(&root.join(".hidden/inside.txt")); + touch(&root.join(".dotfile")); + touch(&root.join("node_modules/dep/index.js")); + touch(&root.join("secret/deep/file.txt")); - if let Some(ref callback) = status_callback { - callback(&format!( - "Extracting text for search indexing {}/{}: {}", - global_index, total_files, fname - )); - } + let ignore = IgnoreSet::compile(&[ + "*.tmp".to_string(), + "node_modules".to_string(), + format!("{}/secret", root.display()), + ]) + .unwrap(); - if let Some(ref progress_cb) = progress_callback { - progress_cb(global_index); - } + 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"]); - let mime_str = fmime.clone().or_else(|| guess_mime(Path::new(fpath))); - let result = match mime_str.as_deref() { - Some(m) => registry.extract(Path::new(fpath), m), - None => Ok(None), - }; - match result { - Ok(Some(mut content)) => { - // Truncate extracted text to configured limit before storage. - if content.text.len() > config.processing.maximum_text_size { - content.text = - safe_truncate_string(&content.text, config.processing.maximum_text_size); - } - let props = content.properties_sorted(); - if let Err(e) = repo::set_content_done( - &tx, - *file_id, - fname, - &content.text, - &props, - config.processing.store_text_for_snippets, - ) { - eprintln!("Warning: set_content_done for {}: {}", fpath, e); - } - } - Ok(None) => { - if let Err(e) = repo::set_content_na(&tx, *file_id) { - eprintln!("Warning: set_content_na for {}: {}", fpath, e); - } - } - Err(reason) => { - if let Err(e) = repo::set_content_failed(&tx, *file_id, &reason) { - eprintln!("Warning: set_content_failed for {}: {}", fpath, e); - } - } - } + // 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(); + names.sort(); + assert_eq!(names, vec![".dotfile", "inside.txt", "keep.txt", "keep2.txt"]); + + std::fs::remove_dir_all(&root).ok(); + } + + /// The watcher spends one inotify descriptor per directory this yields, + /// so it must prune exactly like [`filtered_walk`] — the pruned + /// subtrees are the whole saving. + #[test] + fn filtered_dirs_yields_only_kept_directories() { + let root = tmp_tree(); + touch(&root.join("keep.txt")); + touch(&root.join("sub/nested/keep2.txt")); + touch(&root.join(".hidden/inside.txt")); + touch(&root.join("node_modules/dep/index.js")); + + let ignore = IgnoreSet::compile(&["node_modules".to_string()]).unwrap(); + + let mut names: Vec = filtered_dirs( + root.to_str().unwrap(), + false, + false, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + // The root itself is included (depth 0 is always kept); `.hidden`, + // `node_modules`, and `node_modules/dep` cost nothing. + let root_name = root.file_name().unwrap().to_string_lossy().into_owned(); + let mut want = vec![root_name.clone(), "nested".to_string(), "sub".to_string()]; + want.sort(); + assert_eq!(names, want); + + // include_hidden brings the dotted directory back. + let names: Vec = filtered_dirs( + root.to_str().unwrap(), + false, + true, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert!(names.contains(&".hidden".to_string())); + assert!( + !names.contains(&"node_modules".to_string()), + "ignores still apply with include_hidden" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + /// Files and directories partition the walk: every entry lands in + /// exactly one of the two iterators. + #[test] + fn filtered_dirs_and_filtered_walk_do_not_overlap() { + let root = tmp_tree(); + touch(&root.join("a.txt")); + touch(&root.join("sub/b.txt")); + let ignore = IgnoreSet::compile(&[]).unwrap(); + + let files: Vec<_> = filtered_walk( + root.to_str().unwrap(), + false, + false, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.path().to_path_buf()) + .collect(); + let dirs: Vec<_> = filtered_dirs( + root.to_str().unwrap(), + false, + false, + &ignore, + &UnreadableDirs::default(), + ) + .map(|e| e.path().to_path_buf()) + .collect(); + + assert_eq!(files.len(), 2); + assert_eq!(dirs.len(), 2, "root + sub"); + assert!( + files.iter().all(|f| !dirs.contains(f)), + "a path must not be both a file and a directory" + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn filtered_walk_hidden_root_still_walked() { + // Users explicitly chose their roots — a hidden root dir must not + // silence the whole walk. + let base = tmp_tree(); + 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(); + assert_eq!(names, vec!["app.conf"]); + std::fs::remove_dir_all(&base).ok(); + } + + #[test] + fn classify_uses_mtime_against_the_existing_index() { + let mut existing = HashMap::new(); + existing.insert("/a/known.txt".to_string(), ExistingFileEntry { mtime: 100 }); + + assert_eq!( + classify_for_indexing("/a/new.txt", 100, &existing), + FileIndexAction::Insert, + "a path absent from the index is new" + ); + assert_eq!( + classify_for_indexing("/a/known.txt", 100, &existing), + FileIndexAction::Skip, + "same mtime means nothing to do" + ); + assert_eq!( + classify_for_indexing("/a/known.txt", 101, &existing), + FileIndexAction::Update, + "a changed mtime means re-read" + ); + } + + #[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"); + // 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. + assert_eq!( + path_to_db_string(Path::new(r"\\?\UNC\server\share\a.txt")), + r"\\server\share\a.txt" + ); + // A volume mounted at a folder has no drive letter, so the prefix is + // load-bearing: `Volume{...}\a.txt` is not a path anything can open. + assert_eq!( + path_to_db_string(Path::new(r"\\?\Volume{9f8a}\data\a.txt")), + r"\\?\Volume{9f8a}\data\a.txt" + ); + } + + /// A Remove event names a path that is already gone, so the key for it has + /// to be built from the deepest ancestor that still resolves. + #[test] + fn db_key_for_a_vanished_path_canonicalizes_what_remains() { + let root = tmp_tree(); + let real = root.join("sub"); + std::fs::create_dir_all(&real).unwrap(); + + 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"), + ); + assert_eq!(key, expected, "existing prefix resolved, missing tail kept"); + + // A redundant component in the *existing* part is collapsed, which is + // the whole point — the stored key never contains one. + let odd = root.join("sub").join(".").join("gone.txt"); + let odd_key = db_key_for_missing_path(&odd); + assert!( + !odd_key.contains(&format!("{}.{}", MAIN_SEPARATOR, MAIN_SEPARATOR)), + "unexpected `.` component in {}", + odd_key + ); + + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn db_key_for_an_entirely_missing_path_falls_back_to_the_raw_spelling() { + let nowhere = Path::new("relative-thing-that-does-not-exist.txt"); + assert_eq!(db_key_for_missing_path(nowhere), path_to_db_string(nowhere)); + } + + #[test] + fn unreadable_dirs_match_by_component_not_string_prefix() { + let u = UnreadableDirs::default(); + assert!(!u.covers("/a/b/c.txt"), "an empty set covers nothing"); + + u.record(std::path::PathBuf::from("/a/b")); + assert!(u.covers("/a/b/c.txt")); + assert!(u.covers("/a/b")); + // The bug a naive `str::starts_with` would introduce: /a/bc is a + // sibling of /a/b, and its rows must stay deletable. + assert!(!u.covers("/a/bc/d.txt")); + assert!(!u.covers("/a/other.txt")); + } + + #[test] + fn unreadable_directory_is_reported_rather_than_yielded_as_empty() { + // A directory the walk cannot read must be recorded, so the caller + // can tell "could not look" apart from "the files are gone" — the + // latter deletes index rows. + let root = tmp_tree(); + touch(&root.join("readable/a.txt")); + let locked = root.join("locked"); + std::fs::create_dir_all(&locked).unwrap(); + touch(&locked.join("hidden-from-us.txt")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap(); + + 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(); + + // 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!(!failures.is_empty(), "and that failure must be recorded"); + assert!( + failures.covers(locked.join("hidden-from-us.txt").to_str().unwrap()), + "rows beneath it are protected from stale cleanup" + ); } - tx.commit() - .map_err(|e| format!("Failed to commit transaction: {}", e))?; + std::fs::remove_dir_all(&root).ok(); } - if total_files > 0 && !should_abort(stop_flag, suspend_flag) { - let conn = conn_mutex.lock().unwrap(); - fts_finalize_after_text_indexing(&conn)?; - } + #[test] + fn hash_covers_size_and_head_only() { + let root = tmp_tree(); + let a = root.join("a.bin"); + let b = root.join("b.bin"); + let c = root.join("c.bin"); - Ok(()) + // Same size, same head, differing only in the tail: the documented + // collision (pre-allocated VM images are the real-world case). + std::fs::write(&a, [b"HEAD".as_slice(), &[0u8; 64], b"AAAA"].concat()).unwrap(); + std::fs::write(&b, [b"HEAD".as_slice(), &[0u8; 64], b"BBBB"].concat()).unwrap(); + // 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; + assert_eq!(h(&a), h(&b), "tail differences are invisible by design"); + assert_ne!(h(&a), h(&c), "head differences are caught"); + + // Size participates, so a prefix does not collide with its extension. + let short = root.join("short.bin"); + std::fs::write(&short, b"HEAD").unwrap(); + assert_ne!(h(&a), h(&short)); + + // The head is returned for MIME sniffing rather than re-read. + let (_, head) = get_file_hash(72, &a, 8).unwrap(); + assert_eq!(head, b"HEAD\0\0\0\0", "exactly hash_length bytes"); + let (_, head) = get_file_hash(4, &short, 8).unwrap(); + assert_eq!(head, b"HEAD", "a short file hashes whole"); + + std::fs::remove_dir_all(&root).ok(); + } +} + +#[cfg(test)] +mod count_and_extract_tests { + use super::*; + use crate::db::open_or_recreate; + use crate::db::repo::{insert_file, NewFile}; + use crate::mime::FileType; + use std::sync::atomic::{AtomicBool, Ordering}; + + fn tmp(tag: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "qs-ce-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p + } + + #[test] + fn count_normal_small_tree() { + let root = tmp("count"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + for name in ["a.txt", "b.txt", "sub/c.txt"] { + std::fs::write(root.join(name), b"x").unwrap(); + } + let cancel = AtomicBool::new(false); + let n = count_tree_entries_fast(root.to_str().unwrap(), &cancel).unwrap(); + // find lists the root, the subdir, and the three files. + assert_eq!(n, 5); + std::fs::remove_dir_all(&root).ok(); + } + + #[test] + fn count_cancelled_returns_promptly() { + // A pre-set token must kill the subprocesses on the first poll — + // "/" would otherwise take minutes to scan. + let cancel = AtomicBool::new(true); + let started = std::time::Instant::now(); + let result = count_tree_entries_fast("/", &cancel); + let elapsed = started.elapsed(); + assert!(result.is_err(), "cancelled count must not succeed"); + assert!( + result.unwrap_err().contains("cancelled"), + "error must be recognizable as cancellation" + ); + assert!( + elapsed < std::time::Duration::from_secs(3), + "cancellation took {:?}", + elapsed + ); + // The token is observational only — nothing resets it. + assert!(cancel.load(Ordering::Relaxed)); + } + + #[test] + fn extract_one_batch_is_scoped_to_its_root_range() { + let tree = tmp("extract-tree"); + std::fs::create_dir_all(tree.join("r1")).unwrap(); + std::fs::create_dir_all(tree.join("r2")).unwrap(); + let f1 = tree.join("r1/inside.txt"); + let f2 = tree.join("r2/outside.txt"); + std::fs::write(&f1, "sphinx of black quartz").unwrap(); + std::fs::write(&f2, "judge my vow").unwrap(); + + let db = tmp("extract-db"); + let mut conn = open_or_recreate(db.to_str().unwrap(), "trigram").unwrap(); + { + let tx = conn.transaction().unwrap(); + for f in [&f1, &f2] { + insert_file( + &tx, + &NewFile { + name: f.file_name().unwrap().to_str().unwrap(), + path: f.to_str().unwrap(), + parent: f.parent().unwrap().to_str().unwrap(), + size: std::fs::metadata(f).unwrap().len(), + mtime: 1, + inode: None, + device_id: None, + mime: Some("text/plain"), + ftype: FileType::TEXT, + hash: None, + }, + ) + .unwrap() + .expect("unique"); + } + tx.commit().unwrap(); + } + let conn_mutex = Arc::new(Mutex::new(conn)); + + let registry = Registry::default_set(); + let config = Config::default(); + let stop = Arc::new(Mutex::new(false)); + let suspend = Arc::new(AtomicBool::new(false)); + + let mut cursor = ExtractCursor::for_root(tree.join("r1").to_str().unwrap()); + let scope = extract_scope_prepare(&conn_mutex, &cursor, &config).unwrap(); + assert_eq!(scope.pending, 1, "only r1's file is in range"); + assert_eq!(scope.already_done, 0, "nothing extracted yet"); + + let mut seen_names = Vec::new(); + loop { + let n = extract_one_batch( + &conn_mutex, + &mut cursor, + ®istry, + &config, + &stop, + &suspend, + &mut |name| seen_names.push(name.to_string()), + ) + .unwrap(); + if n == 0 { + break; + } + } + assert_eq!(seen_names, vec!["inside.txt".to_string()]); + + let conn = conn_mutex.lock().unwrap(); + let state = |path: &std::path::Path| -> i64 { + conn.query_row( + "SELECT content_state FROM files WHERE path = ?1", + rusqlite::params![path.to_str().unwrap()], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!(state(&f1), repo::STATE_DONE, "in-range row extracted"); + assert_eq!(state(&f2), repo::STATE_PENDING, "out-of-range row untouched"); + drop(conn); + + // A second run over the unchanged root must report the file as + // already extracted, so progress reads "1 of 1", never "0 of 0". + let cursor2 = ExtractCursor::for_root(tree.join("r1").to_str().unwrap()); + let scope2 = extract_scope_prepare(&conn_mutex, &cursor2, &config).unwrap(); + assert_eq!(scope2.pending, 0); + assert_eq!(scope2.already_done, 1); + let conn = conn_mutex.lock().unwrap(); + let hits: i64 = conn + .query_row( + "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH '\"sphinx\"'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(hits, 1); + + drop(conn); + std::fs::remove_dir_all(&tree).ok(); + std::fs::remove_file(&db).ok(); + } } diff --git a/crates/quicksearch-core/src/incremental.rs b/crates/quicksearch-core/src/incremental.rs new file mode 100644 index 0000000..0d4f618 --- /dev/null +++ b/crates/quicksearch-core/src/incremental.rs @@ -0,0 +1,486 @@ +//! Incremental single-path index updates, driven by watcher events. +//! +//! One [`FsEvent`] becomes one (or a few) small transactions: files row, +//! `documents_text`, and FTS are updated together, so the index is +//! consistent after every commit. The same filters as the full walk apply +//! ([`IgnoreSet`], hidden components, `content_extensions`, size caps) — +//! a watcher event for something the walker would have skipped is a no-op. +//! +//! Renames are handled as remove + re-add: they're rare, and rewriting +//! `path`/`parent` strings plus re-tokenizing the FTS `name` column in +//! place is more machinery than re-extracting one file. +//! +//! Scope note: the watcher only reports paths under the configured roots, +//! so no root containment check is repeated here. + +use std::path::Path; + +use rusqlite::{Connection, OptionalExtension}; + +use crate::config::{content_allowed, Config, IgnoreSet}; +use crate::db::repo; +use crate::extract::Registry; +use crate::file_handling::{ + db_key_for_missing_path, extract_and_store, filtered_walk, UnreadableDirs, + prepare_file_record_from_path, store_inline_text, +}; +use crate::platform::path_has_hidden_component_under; +use crate::query::translator::like_subtree_pattern; +use crate::watcher::FsEvent; + +/// Apply one filesystem event to the index. Missing files are treated as +/// no-ops (a Create followed by a quick delete resolves via the Remove +/// event); unchanged mtimes short-circuit without touching the DB. +pub fn apply_fs_event( + conn: &mut Connection, + event: &FsEvent, + config: &Config, + ignore: &IgnoreSet, + registry: &Registry, +) -> Result<(), String> { + match event { + FsEvent::Create(p) | FsEvent::Modify(p) => upsert_path(conn, p, config, ignore, registry), + FsEvent::Remove(p) => remove_path(conn, p), + FsEvent::Rename { from, to } => { + remove_path(conn, from)?; + upsert_path(conn, to, config, ignore, registry) + } + } +} + +fn upsert_path( + conn: &mut Connection, + path: &Path, + config: &Config, + ignore: &IgnoreSet, + registry: &Registry, +) -> Result<(), String> { + if ignore.matches_path(path) { + return Ok(()); + } + // Measured from the innermost configured root: the walk never filters the + // root it was handed, so a root that is itself hidden (`~/.config/app`, or + // anything under `%LOCALAPPDATA%` on Windows) must not be rejected here — + // that disagreement is what makes the index churn every cycle. + if !config.indexing.include_hidden + && path_has_hidden_component_under(path, &config.resolved_indexing_paths()) + { + return Ok(()); + } + let Ok(meta) = std::fs::metadata(path) else { + // Already gone again — the pending Remove event handles it. + return Ok(()); + }; + if meta.is_dir() { + // A moved-in tree surfaces as one directory event; walk it with + // the same filters as a full run. + let Some(root) = path.to_str() else { + return Ok(()); + }; + let entries: Vec<_> = filtered_walk( + root, + config.indexing.follow_symlinks, + config.indexing.include_hidden, + ignore, + &UnreadableDirs::default(), + ) + .collect(); + for entry in entries { + upsert_file(conn, entry.path(), config, registry)?; + } + Ok(()) + } else { + upsert_file(conn, path, config, registry) + } +} + +fn upsert_file( + conn: &mut Connection, + path: &Path, + config: &Config, + registry: &Registry, +) -> Result<(), String> { + let Some(rec) = prepare_file_record_from_path(path, config, registry) else { + return Ok(()); + }; + + let tx = conn + .transaction() + .map_err(|e| format!("begin incremental tx: {}", e))?; + + let existing: Option<(i64, i64)> = tx + .query_row( + "SELECT id, mtime FROM files WHERE path = ?1", + rusqlite::params![rec.path], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional() + .map_err(|e| format!("lookup {}: {}", rec.path, e))?; + + let file_id = match existing { + Some((_, mtime)) if mtime.max(0) as u64 == rec.mtime => return Ok(()), + Some((id, _)) => { + repo::update_file_basic( + &tx, + &rec.path, + rec.size, + rec.mtime, + Some(&rec.hash), + rec.mime.as_deref(), + rec.ftype, + )?; + id + } + None => match repo::insert_file(&tx, &rec.as_new_file())? { + Some(id) => id, + // Lost a race with another writer on the same path; the row + // that won is current enough. + None => return Ok(()), + }, + }; + + if rec.size > config.processing.maximum_text_file_size + || !content_allowed(Path::new(&rec.path), config) + { + repo::set_content_na(&tx, file_id)?; + } else if let Some(text) = rec.inline_text.as_deref() { + // Small enough that `prepare_file_record_from_path` already read the + // whole file; reopening it here would be the same bytes twice. + store_inline_text(&tx, file_id, &rec, text, config)?; + } else { + extract_and_store( + &tx, + file_id, + &rec.name, + &rec.path, + rec.mime.as_deref(), + registry, + config, + )?; + } + + tx.commit().map_err(|e| format!("commit incremental tx: {}", e)) +} + +fn remove_path(conn: &mut Connection, path: &Path) -> Result<(), String> { + // The insert side stores a canonicalized path, so the raw event spelling + // is not a usable key — but the file is already gone, so `canonicalize` + // cannot be called on it directly either. + let path_str = db_key_for_missing_path(path); + let tx = conn + .transaction() + .map_err(|e| format!("begin incremental tx: {}", e))?; + + repo::delete_file_by_path(&tx, &path_str)?; + + // Directory removals surface as one event for the directory itself — + // sweep everything indexed beneath it. + let subtree: Vec = { + let mut stmt = tx + .prepare("SELECT path FROM files WHERE path LIKE ?1 ESCAPE '\\'") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(rusqlite::params![like_subtree_pattern(&path_str)], |r| { + r.get::<_, String>(0) + }) + .map_err(|e| e.to_string())?; + rows.collect::, _>>().map_err(|e| e.to_string())? + }; + for p in &subtree { + repo::delete_file_by_path(&tx, p)?; + } + + tx.commit().map_err(|e| format!("commit incremental tx: {}", e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::open_or_recreate; + + struct Fixture { + conn: Connection, + dir: std::path::PathBuf, + db: std::path::PathBuf, + config: Config, + ignore: IgnoreSet, + registry: Registry, + } + + impl Fixture { + fn new() -> Fixture { + let stamp = format!( + "{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ); + let dir = std::env::temp_dir().join(format!("qs-incr-{}", stamp)); + std::fs::create_dir_all(&dir).unwrap(); + let db = std::env::temp_dir().join(format!("qs-incr-{}.sqlite", stamp)); + let conn = open_or_recreate(db.to_str().unwrap(), "trigram").unwrap(); + let config = Config::default(); + let ignore = IgnoreSet::compile(&config.indexing.ignore_patterns).unwrap(); + Fixture { + conn, + dir, + db, + config, + ignore, + registry: Registry::default_set(), + } + } + + fn apply(&mut self, event: &FsEvent) { + apply_fs_event(&mut self.conn, event, &self.config, &self.ignore, &self.registry) + .unwrap(); + } + + fn write(&self, name: &str, content: &str) -> std::path::PathBuf { + let p = self.dir.join(name); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, content).unwrap(); + p + } + + /// The key the index actually stores. Must go through + /// `path_to_db_string`, or every lookup here misses the + /// `\\?\`-stripped spelling on Windows. + fn canonical(&self, p: &Path) -> String { + crate::file_handling::path_to_db_string(&p.canonicalize().unwrap()) + } + + fn row(&self, path: &str) -> Option<(i64, i64, i64)> { + self.conn + .query_row( + "SELECT id, mtime, content_state FROM files WHERE path = ?1", + rusqlite::params![path], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .optional() + .unwrap() + } + + fn counts(&self) -> (i64, i64, i64) { + let files = self + .conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + let fts = self + .conn + .query_row("SELECT COUNT(*) FROM searchabletext", [], |r| r.get(0)) + .unwrap(); + let texts = self + .conn + .query_row("SELECT COUNT(*) FROM documents_text", [], |r| r.get(0)) + .unwrap(); + (files, fts, texts) + } + + fn fts_hits(&self, term: &str) -> i64 { + self.conn + .query_row( + "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH ?1", + rusqlite::params![format!("\"{}\"", term)], + |r| r.get(0), + ) + .unwrap() + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + std::fs::remove_dir_all(&self.dir).ok(); + std::fs::remove_file(&self.db).ok(); + } + } + + #[test] + fn create_indexes_file_and_content() { + let mut f = Fixture::new(); + let p = f.write("hello.txt", "greetings earthling"); + f.apply(&FsEvent::Create(p.clone())); + + let canonical = f.canonical(&p); + let (_, _, content_state) = f.row(&canonical).expect("row exists"); + assert_eq!(content_state, repo::STATE_DONE); + assert_eq!(f.counts(), (1, 1, 1), "files + FTS + text all written"); + assert_eq!(f.fts_hits("earthling"), 1); + } + + #[test] + fn modify_with_same_mtime_is_noop_and_changed_mtime_reextracts() { + let mut f = Fixture::new(); + let p = f.write("doc.txt", "first version"); + f.apply(&FsEvent::Create(p.clone())); + let canonical = f.canonical(&p); + let (id1, mtime1, _) = f.row(&canonical).unwrap(); + + // Same mtime → no-op (id unchanged, no re-extraction). + f.apply(&FsEvent::Modify(p.clone())); + let (id2, mtime2, _) = f.row(&canonical).unwrap(); + assert_eq!((id1, mtime1), (id2, mtime2)); + + // Bump mtime and content → re-extracted, FTS follows. + std::fs::write(&p, "second edition entirely").unwrap(); + let newer = std::time::SystemTime::now() + std::time::Duration::from_secs(5); + let file = std::fs::File::options().write(true).open(&p).unwrap(); + file.set_modified(newer).unwrap(); + drop(file); + f.apply(&FsEvent::Modify(p.clone())); + assert_eq!(f.fts_hits("edition"), 1); + assert_eq!(f.fts_hits("version"), 0, "stale tokens removed"); + assert_eq!(f.counts(), (1, 1, 1), "still exactly one of everything"); + } + + #[test] + fn remove_file_cleans_all_tables() { + let mut f = Fixture::new(); + let p = f.write("bye.txt", "ephemeral text"); + f.apply(&FsEvent::Create(p.clone())); + let canonical = f.canonical(&p); + std::fs::remove_file(&p).unwrap(); + f.apply(&FsEvent::Remove(canonical.clone().into())); + assert!(f.row(&canonical).is_none()); + assert_eq!(f.counts(), (0, 0, 0)); + } + + #[test] + fn directory_create_and_remove_walks_subtree() { + let mut f = Fixture::new(); + f.write("tree/a.txt", "alpha content"); + f.write("tree/nested/b.txt", "beta content"); + f.write("tree/.hidden.txt", "should not index"); + f.write("tree/junk.tmp", "ignored pattern"); + let tree = f.dir.join("tree"); + f.apply(&FsEvent::Create(tree.clone())); + assert_eq!(f.counts().0, 2, "hidden + ignored excluded"); + + let canonical_tree = f.canonical(&tree); + std::fs::remove_dir_all(&tree).unwrap(); + f.apply(&FsEvent::Remove(canonical_tree.into())); + assert_eq!(f.counts(), (0, 0, 0), "subtree swept"); + } + + #[test] + fn rename_moves_the_row() { + let mut f = Fixture::new(); + let from = f.write("old-name.txt", "movable feast"); + f.apply(&FsEvent::Create(from.clone())); + let canonical_from = f.canonical(&from); + + let to = f.dir.join("new-name.txt"); + std::fs::rename(&from, &to).unwrap(); + f.apply(&FsEvent::Rename { + from: canonical_from.clone().into(), + to: to.clone(), + }); + + assert!(f.row(&canonical_from).is_none()); + let canonical_to = f.canonical(&to); + assert!(f.row(&canonical_to).is_some()); + assert_eq!(f.counts(), (1, 1, 1)); + assert_eq!(f.fts_hits("feast"), 1); + } + + #[test] + fn ignored_and_hidden_events_are_noops() { + let mut f = Fixture::new(); + let ignored = f.write("junk.tmp", "x"); + let hidden = f.write(".secret", "x"); + f.apply(&FsEvent::Create(ignored)); + f.apply(&FsEvent::Create(hidden)); + // Missing file too. + f.apply(&FsEvent::Create(f.dir.join("never-existed.txt"))); + assert_eq!(f.counts(), (0, 0, 0)); + } + + #[test] + fn content_extension_filter_gates_extraction() { + let mut f = Fixture::new(); + f.config.indexing.content_extensions = vec!["md".into()]; + let txt = f.write("listed-only.txt", "text body here"); + f.apply(&FsEvent::Create(txt.clone())); + + let canonical = f.canonical(&txt); + let (_, _, content_state) = f.row(&canonical).expect("row listed"); + assert_eq!( + content_state, + repo::STATE_NA, + "filename indexed, content skipped" + ); + assert_eq!(f.counts(), (1, 0, 0)); + } + + /// A Remove event whose path is spelled differently from the stored key + /// must still delete the row. `dir/./f.txt` and `dir/f.txt` are the same + /// file; only the canonicalized spelling is in the index. + #[test] + fn remove_with_a_non_canonical_spelling_still_deletes() { + let mut f = Fixture::new(); + let p = f.write("sub/gone.txt", "vanishing text"); + f.apply(&FsEvent::Create(p.clone())); + assert_eq!(f.counts(), (1, 1, 1)); + + std::fs::remove_file(&p).unwrap(); + // Same file, spelled with a redundant `.` component. + let odd = f.dir.join("sub").join(".").join("gone.txt"); + f.apply(&FsEvent::Remove(odd)); + assert_eq!(f.counts(), (0, 0, 0), "row removed despite the spelling"); + } + + /// The subtree sweep must not take siblings whose names merely share a + /// string prefix — `tree2` is not inside `tree`. + #[test] + fn subtree_sweep_spares_prefix_siblings() { + let mut f = Fixture::new(); + f.write("tree/a.txt", "alpha content"); + f.write("tree2/b.txt", "beta content"); + let tree = f.dir.join("tree"); + f.apply(&FsEvent::Create(tree.clone())); + f.apply(&FsEvent::Create(f.dir.join("tree2"))); + assert_eq!(f.counts().0, 2); + + let canonical_tree = f.canonical(&tree); + std::fs::remove_dir_all(&tree).unwrap(); + f.apply(&FsEvent::Remove(canonical_tree.into())); + + assert_eq!(f.counts().0, 1, "only tree/ was swept"); + let survivor = f.canonical(&f.dir.join("tree2").join("b.txt")); + assert!(f.row(&survivor).is_some(), "tree2 untouched"); + } + + /// A directory whose name contains a LIKE metacharacter must be swept + /// literally, not as a wildcard. + #[test] + fn subtree_sweep_treats_like_metacharacters_literally() { + let mut f = Fixture::new(); + f.write("a_b/inside.txt", "underscore dir"); + f.write("axb/other.txt", "wildcard bait"); + f.apply(&FsEvent::Create(f.dir.join("a_b"))); + f.apply(&FsEvent::Create(f.dir.join("axb"))); + assert_eq!(f.counts().0, 2); + + let target = f.dir.join("a_b"); + let canonical = f.canonical(&target); + std::fs::remove_dir_all(&target).unwrap(); + f.apply(&FsEvent::Remove(canonical.into())); + + assert_eq!(f.counts().0, 1, "`_` must not match `x`"); + let survivor = f.canonical(&f.dir.join("axb").join("other.txt")); + assert!(f.row(&survivor).is_some()); + } + + #[test] + fn oversize_files_get_content_na() { + let mut f = Fixture::new(); + f.config.processing.maximum_text_file_size = 4; + let p = f.write("big.txt", "way more than four bytes"); + f.apply(&FsEvent::Create(p.clone())); + let canonical = f.canonical(&p); + let (_, _, content_state) = f.row(&canonical).unwrap(); + assert_eq!(content_state, repo::STATE_NA); + } +} diff --git a/crates/quicksearch-core/src/indexing.rs b/crates/quicksearch-core/src/indexing.rs index 1f26657..627daad 100644 --- a/crates/quicksearch-core/src/indexing.rs +++ b/crates/quicksearch-core/src/indexing.rs @@ -1,1094 +1,918 @@ -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex, mpsc}; -use std::thread; -use std::time::{Duration, Instant}; -use std::process::Command; -use std::collections::HashSet; -use rusqlite::{Connection, params}; -use walkdir::DirEntry; - -use crate::file_handling::{ - classify_dir_entry_for_indexing, - cleanup_stale_index_entries, - count_tree_entries_fast, - indexed_walk_file_entries, - load_existing_files, - path_has_hidden_component, - process_batch_inserts_files_only, - process_batch_updates_files_only, - process_text_indexing, - FileIndexAction, -}; -use crate::config::Config; -use crate::db; - -#[derive(Debug, Clone)] -pub struct SearchResultRow { - pub values: Vec, -} - -#[derive(Debug, Clone)] -pub struct SearchResult { - pub columns: Vec, - pub rows: Vec, -} - -#[derive(Debug, Clone)] -pub enum IndexingStatus { - Idle, - CountingFiles { - _entries_scanned: usize, - _indexable_files_counted: usize, - current_file: Option, - start_time: Instant, - }, - RunningFileIndex { - files_processed: usize, - total_files: Option, - current_file: Option, - start_time: Instant, - }, - RunningTextIndex { - files_processed: usize, - current_file: Option, - start_time: Instant, - }, - Stopping, - Error(String), -} - -#[derive(Debug, Clone)] -pub enum IndexingCommand { - Start { - /// One or more directory roots to index. Order determines walk order; - /// duplicates are silently dropped at run time. - paths: Vec, - db_path: String, - config: Config, - }, - Stop, -} - -#[derive(Debug)] -pub struct IndexingService { - status: Arc>, - command_tx: mpsc::Sender, - db_connection: Arc>>>>, - suspend_flag: Arc, - _handle: thread::JoinHandle<()>, -} - -/// Polling interval for `should_abort` while suspended. -const SUSPEND_POLL_MS: u64 = 100; - -/// Map a rusqlite/SQLite error string into the tagged form the GUI's -/// search panel recognises (so it can pop the corruption-recovery dialog, -/// special-case FTS5 syntax errors, etc.). Centralized so both generic -/// `execute_search` and the fulltext-specific path classify errors the -/// same way. -fn classify_sql_err(error_msg: &str) -> String { - if error_msg.contains("malformed") - || error_msg.contains("corrupt") - || error_msg.contains("database disk image is malformed") - { - format!("DATABASE_CORRUPTED: {}", error_msg) - } else if error_msg.contains("fts5: syntax error") { - "Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.".into() - } else { - format!("Failed to execute query: {}", error_msg) - } -} - -/// Combined stop/suspend check used by worker loops. Returns `true` iff the -/// 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 { - loop { - if *stop.lock().unwrap() { - return true; - } - if !suspend.load(Ordering::Relaxed) { - return false; - } - thread::sleep(Duration::from_millis(SUSPEND_POLL_MS)); - } -} - -/// Set process priority for background operation -// fn set_background_priority() { -// #[cfg(windows)] -// { -// use std::os::windows::raw::HANDLE; - -// // Windows implementation -// extern "system" { -// fn GetCurrentProcess() -> HANDLE; -// fn SetPriorityClass(hprocess: HANDLE, dwpriorityclass: u32) -> i32; -// } - -// const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x00004000; -// unsafe { -// SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS); -// } -// } - -// #[cfg(unix)] -// { -// // Unix implementation -// use std::os::unix::process::CommandExt; -// unsafe { -// libc::nice(10); // Lower priority -// } -// } -// } - -impl IndexingService { - pub fn new() -> Self { - let status = Arc::new(Mutex::new(IndexingStatus::Idle)); - let (command_tx, command_rx) = mpsc::channel(); - let db_connection = Arc::new(Mutex::new(None)); - let suspend_flag = Arc::new(AtomicBool::new(false)); - - let status_clone = status.clone(); - let db_connection_clone = db_connection.clone(); - let suspend_clone = suspend_flag.clone(); - let handle = thread::spawn(move || { - Self::indexing_thread(status_clone, command_rx, db_connection_clone, suspend_clone); - }); - - IndexingService { - status, - command_tx, - db_connection, - suspend_flag, - _handle: handle, - } - } - - /// Pause the indexer. All worker loops that call [`should_abort`] will - /// block until [`resume`](Self::resume) is called. No-op if already - /// suspended. Does not stop the worker — stop_indexing is still the way - /// to abort. - pub fn suspend(&self) { - self.suspend_flag.store(true, Ordering::Relaxed); - } - - /// Resume indexing after [`suspend`](Self::suspend). No-op if not - /// suspended. - pub fn resume(&self) { - self.suspend_flag.store(false, Ordering::Relaxed); - } - - pub fn is_suspended(&self) -> bool { - self.suspend_flag.load(Ordering::Relaxed) - } - - /// Start indexing one or more roots. Paths are walked in order; duplicate - /// or nested roots are de-duplicated by the indexer. At least one path is - /// required. - pub fn start_indexing( - &self, - paths: Vec, - db_path: String, - config: Config, - ) -> Result<(), String> { - if paths.is_empty() { - return Err("start_indexing requires at least one path".into()); - } - self.command_tx - .send(IndexingCommand::Start { paths, db_path, config }) - .map_err(|e| format!("Failed to send start command: {}", e)) - } - - pub fn stop_indexing(&self) -> Result<(), String> { - // First send the stop command - self.command_tx - .send(IndexingCommand::Stop) - .map_err(|e| format!("Failed to send stop command: {}", e))?; - - // Wait for indexing to transition to stopping state - let mut attempts = 0; - while attempts < 50 { // Wait up to 5 seconds - match self.get_status() { - IndexingStatus::Stopping => break, - IndexingStatus::Idle => return Ok(()), // Already stopped - IndexingStatus::Error(_) => return Ok(()), // Consider error state as stopped - _ => { - std::thread::sleep(std::time::Duration::from_millis(100)); - attempts += 1; - } - } - } - - // Flush and close database connection if it exists - if let Ok(mut db_opt) = self.db_connection.lock() { - if let Some(db_conn_arc) = db_opt.take() { - if let Ok(conn) = db_conn_arc.lock() { - // Re-enable journal mode and synchronous writes for proper flushing - let _ = conn.execute_batch( - "PRAGMA journal_mode = DELETE; - PRAGMA synchronous = FULL;" - ); - - // Force a checkpoint to flush any remaining WAL data - let _ = conn.execute("PRAGMA wal_checkpoint(FULL);", ()); - - // Explicitly close the connection by dropping it - drop(conn); - } - } - } - - Ok(()) - } - - pub fn get_status(&self) -> IndexingStatus { - self.status.lock().unwrap().clone() - } - - /// Force graceful shutdown - used for signal handling - pub fn graceful_shutdown(&self) -> Result<(), String> { - self.stop_indexing() - } - - /// Fulltext-specific search path that emits rendered snippets. Runs the - /// SQL produced by [`crate::search_sql::build_select`] for a fulltext - /// `SearchArgs`, which returns `(name, path, file_id, text_zstd)`, then - /// decompresses each text blob and hands it to [`crate::snippet::render`] - /// together with the query terms. Case-sensitive mode is re-applied in - /// Rust since contentless FTS5 no longer stores the raw text. - /// - /// The shape of the returned [`SearchResult`] matches what the GUI's - /// results table expects: columns `(name, path, snippet)`. - pub fn execute_fulltext_search( - &self, - db_path: &str, - args: &crate::search_sql::SearchArgs, - limit: u32, - offset: u32, - ) -> Result, String> { - use crate::search_sql::{build_select, fulltext_terms}; - use crate::snippet; - - let sql = build_select(args, limit, offset)?; - let terms = fulltext_terms(args); - let term_refs: Vec<&str> = terms.iter().map(String::as_str).collect(); - - let conn = db::open_or_recreate(db_path, "trigram").map_err(|e| { - if e.contains("corrupt") || e.contains("malformed") { - format!("DATABASE_CORRUPTED: {}", e) - } else { - e - } - })?; - - let mut stmt = conn - .prepare(&sql) - .map_err(|e| classify_sql_err(&e.to_string()))?; - - let rows_iter = stmt - .query_map([], |row| { - let name: String = row.get(0)?; - let path: String = row.get(1)?; - let _file_id: i64 = row.get(2)?; - let blob: Option> = row.get(3)?; - Ok((name, path, blob)) - }) - .map_err(|e| classify_sql_err(&e.to_string()))?; - - let snippet_opts = snippet::Options::default(); - let mut out_rows: Vec = Vec::new(); - for r in rows_iter { - let (name, path, blob) = - r.map_err(|e| classify_sql_err(&e.to_string()))?; - let body = match blob { - None => String::new(), - Some(bytes) if bytes.is_empty() => String::new(), - Some(bytes) => match zstd::decode_all(bytes.as_slice()) { - Ok(raw) => String::from_utf8_lossy(&raw).into_owned(), - Err(_) => String::new(), - }, - }; - - // Case-sensitive post-filter: every query term must appear with - // the exact case supplied by the user. Applied to the body - // text; filename hits are still matched by the FTS MATCH which - // is case-insensitive (filenames are usually mixed case and the - // user probably doesn't care). Exact-phrase mode treats the - // whole quoted phrase as one token. - if args.fulltext_case_sensitive && !body.is_empty() { - let all_present = terms.iter().all(|t| body.contains(t.as_str())); - if !all_present { - continue; - } - } - - let rendered = if body.is_empty() { - String::new() - } else { - snippet::render(&body, &term_refs, &snippet_opts) - }; - out_rows.push(SearchResultRow { - values: vec![name, path, rendered], - }); - } - - Ok(vec![SearchResult { - columns: vec!["name".into(), "path".into(), "snippet".into()], - rows: out_rows, - }]) - } - - /// Execute a search query against the database - pub fn execute_search(&self, db_path: &str, query: &str) -> Result, String> { - let conn = db::open_or_recreate(db_path, "trigram") - .map_err(|e| { - if e.contains("corrupt") || e.contains("malformed") { - format!("DATABASE_CORRUPTED: {}", e) - } else { - e - } - })?; - - let mut stmt = conn.prepare(query) - .map_err(|e| { - let error_msg = e.to_string(); - if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { - format!("DATABASE_CORRUPTED: {}", error_msg) - } else if error_msg.contains("fts5: syntax error") { - format!("Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.") - } else { - format!("Failed to prepare query: {}", error_msg) - } - })?; - - let column_count = stmt.column_count(); - let column_names: Vec = (0..column_count) - .map(|i| stmt.column_name(i).unwrap_or("").to_string()) - .collect(); - - let rows = stmt.query_map([], |row| { - let mut values = Vec::new(); - for i in 0..column_count { - let value = match row.get_ref(i)? { - rusqlite::types::ValueRef::Null => "NULL".to_string(), - rusqlite::types::ValueRef::Integer(i) => i.to_string(), - rusqlite::types::ValueRef::Real(f) => f.to_string(), - rusqlite::types::ValueRef::Text(t) => String::from_utf8_lossy(t).to_string(), - rusqlite::types::ValueRef::Blob(b) => format!("BLOB({} bytes)", b.len()), - }; - values.push(value); - } - Ok(SearchResultRow { values }) - }) - .map_err(|e| { - let error_msg = e.to_string(); - if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { - format!("DATABASE_CORRUPTED: {}", error_msg) - } else if error_msg.contains("fts5: syntax error") { - format!("Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.") - } else { - format!("Failed to execute query: {}", error_msg) - } - })?; - - let mut results = Vec::new(); - for row in rows { - match row { - Ok(search_row) => results.push(search_row), - Err(e) => { - let error_msg = e.to_string(); - if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { - return Err(format!("DATABASE_CORRUPTED: {}", error_msg)); - } else if error_msg.contains("fts5: syntax error") { - return Err(format!("Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.")); - } else { - return Err(format!("Error reading row: {}", error_msg)); - } - } - } - } - - Ok(vec![SearchResult { - columns: column_names, - rows: results, - }]) - } - - /// Open file explorer to the directory containing the specified file path - pub fn open_file_explorer(&self, file_path: &str) -> Result<(), String> { - #[cfg(windows)] - { - Command::new("explorer") - .arg("/select,") - .arg(file_path) - .spawn() - .map_err(|e| format!("Failed to open file explorer: {}", e))?; - } - - #[cfg(target_os = "macos")] - { - Command::new("open") - .arg("-R") - .arg(file_path) - .spawn() - .map_err(|e| format!("Failed to open file explorer: {}", e))?; - } - - #[cfg(target_os = "linux")] - { - let path = std::path::Path::new(file_path); - let dir_path = if path.is_file() { - path.parent().unwrap_or(path) - } else { - path - }; - - // Try different file managers - let managers = ["xdg-open", "nautilus", "dolphin", "thunar", "pcmanfm"]; - let mut success = false; - - for manager in &managers { - if let Ok(_) = Command::new(manager) - .arg(dir_path) - .spawn() { - success = true; - break; - } - } - - if !success { - return Err("No suitable file manager found".to_string()); - } - } - - Ok(()) - } - - /// Clean up Windows UNC prefixes (\\?\) from existing `files.path` - /// entries. Relic of pre-A layouts where paths were stored with the - /// prefix; current code strips them at insert time, so this is a - /// one-shot cleanup users can invoke manually if needed. - #[allow(dead_code)] - pub fn clean_unc_prefixes(&self, db_path: &str) -> Result<(), String> { - let conn = Connection::open(db_path) - .map_err(|e| format!("Failed to open database: {}", e))?; - conn.execute( - "UPDATE files SET path = SUBSTR(path, 5) WHERE path LIKE '\\\\?\\%'", - (), - ) - .map_err(|e| format!("Failed to update files table: {}", e))?; - Ok(()) - } - - /// Check if the database is corrupted or malformed - #[allow(dead_code)] - pub fn check_database_health(&self, db_path: &str) -> Result { - match Connection::open(db_path) { - Ok(conn) => { - // Try to run integrity check - match conn.prepare("PRAGMA integrity_check") { - Ok(mut stmt) => { - match stmt.query_row([], |row| { - let result: String = row.get(0)?; - Ok(result == "ok") - }) { - Ok(is_ok) => Ok(is_ok), - Err(_) => Ok(false) - } - }, - Err(_) => Ok(false) - } - }, - Err(_) => Ok(false) - } - } - - /// Check if configuration changes require index recreation - pub fn check_config_validation(&self, db_path: &str, config: &Config, indexing_path: &str) -> Result>, String> { - let conn = db::open_or_recreate(db_path, &config.processing.tokenize)?; - Self::validate_config(&conn, config, indexing_path) - } - - /// Stop indexing and delete the database file for a clean rebuild - pub fn delete_index_for_rebuild(&self, db_path: &str) -> Result<(), String> { - // Stop any running indexing first - self.stop_indexing() - .map_err(|e| format!("Failed to stop indexing: {}", e))?; - - // Wait for indexing to actually stop - let mut attempts = 0; - while attempts < 50 { // Wait up to 5 seconds - match self.get_status() { - IndexingStatus::Idle => break, - IndexingStatus::Stopping - | IndexingStatus::CountingFiles { .. } - | IndexingStatus::RunningFileIndex { .. } - | IndexingStatus::RunningTextIndex { .. } => { - std::thread::sleep(std::time::Duration::from_millis(100)); - attempts += 1; - } - IndexingStatus::Error(_) => break, // Consider error state as stopped - } - } - - // Delete the database file - if std::path::Path::new(db_path).exists() { - std::fs::remove_file(db_path) - .map_err(|e| format!("Failed to delete database file: {}", e))?; - } - - Ok(()) - } - - fn indexing_thread( - status: Arc>, - command_rx: mpsc::Receiver, - db_connection: Arc>>>>, - suspend_flag: Arc, - ) { - let stop_flag = Arc::new(Mutex::new(false)); - let mut indexing_handle: Option> = None; - - while let Ok(command) = command_rx.recv() { - match command { - IndexingCommand::Start { paths, db_path, config } => { - if matches!( - *status.lock().unwrap(), - IndexingStatus::CountingFiles { .. } - | IndexingStatus::RunningFileIndex { .. } - | IndexingStatus::RunningTextIndex { .. } - ) { - continue; // Already running - } - - // Join any previous indexing thread - if let Some(handle) = indexing_handle.take() { - let _ = handle.join(); - } - - *stop_flag.lock().unwrap() = false; - *status.lock().unwrap() = if config.processing.precount_files_for_progress { - IndexingStatus::CountingFiles { - _entries_scanned: 0, - _indexable_files_counted: 0, - current_file: Some("Preparing database...".to_string()), - start_time: Instant::now(), - } - } else { - IndexingStatus::RunningFileIndex { - files_processed: 0, - total_files: None, - current_file: None, - start_time: Instant::now(), - } - }; - - // Run indexing in a separate thread - let status_clone = status.clone(); - let stop_flag_clone = stop_flag.clone(); - let paths_owned = paths.clone(); - let db_path_owned = db_path.clone(); - let config_owned = config.clone(); - - let db_connection_clone = db_connection.clone(); - let suspend_clone = suspend_flag.clone(); - indexing_handle = Some(thread::spawn(move || { - if let Err(e) = Self::run_indexing(&status_clone, &paths_owned, &db_path_owned, &stop_flag_clone, &suspend_clone, &config_owned, &db_connection_clone) { - *status_clone.lock().unwrap() = IndexingStatus::Error(e); - } else { - // Only set to Idle if we weren't stopped - if !*stop_flag_clone.lock().unwrap() { - *status_clone.lock().unwrap() = IndexingStatus::Idle; - } - } - - // Clear the database connection when indexing completes - if let Ok(mut db_opt) = db_connection_clone.lock() { - *db_opt = None; - } - })); - } - IndexingCommand::Stop => { - if matches!( - *status.lock().unwrap(), - IndexingStatus::CountingFiles { .. } - | IndexingStatus::RunningFileIndex { .. } - | IndexingStatus::RunningTextIndex { .. } - ) { - *status.lock().unwrap() = IndexingStatus::Stopping; - *stop_flag.lock().unwrap() = true; - } - } - } - } - - // Clean up any remaining indexing thread - if let Some(handle) = indexing_handle { - let _ = handle.join(); - } - } - - fn file_index_status_callback( - status: &Arc>, - ) -> Box { - let st = status.clone(); - Box::new(move |file_status: &str| { - if let Ok(mut status_guard) = st.lock() { - if let IndexingStatus::RunningFileIndex { ref mut current_file, .. } = *status_guard - { - *current_file = Some(file_status.to_string()); - } - } - }) - } - - fn run_indexing( - status: &Arc>, - paths: &[String], - db_path: &str, - stop_flag: &Arc>, - suspend_flag: &Arc, - config: &Config, - db_connection: &Arc>>>>, - ) -> Result<(), String> { - if paths.is_empty() { - return Err("run_indexing: no paths provided".into()); - } - - // De-duplicate while preserving order. Roots are canonicalized first - // so `/home/jeremy` and `/home/jeremy/` (or a symlink to either) - // collapse to one walk. Pure nested-root deduplication (skip a root - // that is a prefix of an already-walked root) is handled by the - // per-file `seen_paths` set below. - let mut seen_roots = HashSet::new(); - let roots: Vec = paths - .iter() - .map(|p| { - std::path::Path::new(p) - .canonicalize() - .ok() - .map(|c| { - let s = c.to_string_lossy().to_string(); - if s.starts_with("\\\\?\\") { - s[4..].to_string() - } else { - s - } - }) - .unwrap_or_else(|| p.clone()) - }) - .filter(|p| seen_roots.insert(p.clone())) - .collect(); - - // Open and migrate the database to the current schema version. - let conn = db::open_or_recreate(db_path, &config.processing.tokenize)?; - - // Update configuration (for new installations or when no validation issues). - // `indexing_path` in the validation table stores the joined list so - // adding/removing a root triggers the same rebuild prompt as changing - // the legacy single path did. - Self::update_config(&conn, config, &roots.join("\n"))?; - - // Load existing files from database for incremental indexing - let existing_files = { - let conn_ref = &conn; - load_existing_files(conn_ref) - .map_err(|e| format!("Failed to load existing files: {}", e))? - }; - - 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()); - } - - let progress_display_total: Option = - if config.processing.precount_files_for_progress { - if *stop_flag.lock().unwrap() { - if let Ok(mut status_guard) = status.lock() { - *status_guard = IndexingStatus::Idle; - } - return Ok(()); - } - if let Ok(mut g) = status.lock() { - if let IndexingStatus::CountingFiles { ref mut current_file, .. } = *g { - *current_file = Some("Counting paths (shell)...".to_string()); - } - } - let mut n: usize = 0; - for root in &roots { - if *stop_flag.lock().unwrap() { - break; - } - // A failing precount on one root (e.g. permission denied) - // shouldn't abort all indexing; just skip its contribution. - match count_tree_entries_fast(root) { - Ok(c) => n += c, - Err(e) => eprintln!("Warning: precount for {}: {}", root, e), - } - } - if *stop_flag.lock().unwrap() { - if let Ok(mut status_guard) = status.lock() { - *status_guard = IndexingStatus::Idle; - } - return Ok(()); - } - if let Ok(mut status_guard) = status.lock() { - *status_guard = IndexingStatus::RunningFileIndex { - files_processed: 0, - total_files: Some(n), - current_file: None, - start_time: Instant::now(), - }; - } - Some(n) - } else { - None - }; - - let batch_size = config.processing.batch_size; - let mut pending_updates: Vec<(DirEntry, usize)> = Vec::new(); - let mut pending_inserts: Vec<(DirEntry, usize)> = Vec::new(); - // Every path seen during this walk, regardless of outcome. Dedupes - // duplicate visits that can happen when two roots overlap (e.g. - // `/home` and `/home/user`) or when canonicalization collapses - // symlinks. Also feeds the stale-entry cleanup at the end of the walk. - let mut seen_paths: HashSet = HashSet::new(); - let mut visit: usize = 0; - let mut had_incremental_work = false; - let flush_updates = |buf: &mut Vec<(DirEntry, usize)>| -> Result<(), String> { - if buf.is_empty() { - return Ok(()); - } - process_batch_updates_files_only( - &conn_mutex, - buf.as_slice(), - stop_flag, - Some(Self::file_index_status_callback(status)), - None, - config, - progress_display_total, - )?; - buf.clear(); - Ok(()) - }; - - let flush_inserts = |buf: &mut Vec<(DirEntry, usize)>| -> Result<(), String> { - if buf.is_empty() { - return Ok(()); - } - process_batch_inserts_files_only( - &conn_mutex, - buf.as_slice(), - stop_flag, - Some(Self::file_index_status_callback(status)), - None, - config, - progress_display_total, - )?; - buf.clear(); - Ok(()) - }; - - let root_iter = roots - .iter() - .flat_map(|r| indexed_walk_file_entries(r, config.processing.follow_symlinks)); - for entry in root_iter { - if should_abort(stop_flag, suspend_flag) { - flush_updates(&mut pending_updates)?; - flush_inserts(&mut pending_inserts)?; - if let Ok(mut status_guard) = status.lock() { - *status_guard = IndexingStatus::Idle; - } - return Ok(()); - } - - visit += 1; - if let Ok(mut g) = status.lock() { - if let IndexingStatus::RunningFileIndex { - ref mut files_processed, - .. - } = *g - { - *files_processed = visit; - } - } - - if !config.processing.include_hidden && path_has_hidden_component(entry.path()) { - continue; - } - - let action = classify_dir_entry_for_indexing(&entry, &existing_files); - let Some(action) = action else { - continue; - }; - let current_path = entry - .path() - .canonicalize() - .ok() - .map(|fp| { - let path_str = fp.to_string_lossy().to_string(); - if path_str.starts_with("\\\\?\\") { - path_str[4..].to_string() - } else { - path_str - } - }); - - // Skip this entry if its canonical path was already queued in - // this run. Prevents UNIQUE(path) violations when overlapping - // roots or symlinks lead the walker to the same file twice. - let path_for_dedup = match ¤t_path { - Some(p) => p.clone(), - None => continue, - }; - if !seen_paths.insert(path_for_dedup) { - continue; - } - - match action { - FileIndexAction::Skip => {} - FileIndexAction::Update => { - had_incremental_work = true; - pending_updates.push((entry, visit)); - if pending_updates.len() >= batch_size { - flush_updates(&mut pending_updates)?; - } - } - FileIndexAction::Insert => { - had_incremental_work = true; - pending_inserts.push((entry, visit)); - if pending_inserts.len() >= batch_size { - flush_inserts(&mut pending_inserts)?; - } - } - } - } - - flush_updates(&mut pending_updates)?; - flush_inserts(&mut pending_inserts)?; - - let stale_paths: Vec = existing_files - .keys() - .filter(|p| !seen_paths.contains(*p)) - .cloned() - .collect(); - let stale_deleted = cleanup_stale_index_entries( - &conn_mutex, - stale_paths.as_slice(), - stop_flag, - suspend_flag, - Some(Self::file_index_status_callback(status)), - )?; - if stale_deleted > 0 { - had_incremental_work = true; - } - - if !had_incremental_work { - if let Ok(mut status_guard) = status.lock() { - if let IndexingStatus::RunningFileIndex { ref mut current_file, .. } = *status_guard - { - *current_file = Some("File index is up to date".to_string()); - } - } - } - - // Check for stop signal (and park if suspended) before starting text indexing. - if should_abort(stop_flag, suspend_flag) { - if let Ok(mut status_guard) = status.lock() { - *status_guard = IndexingStatus::Idle; - } - return Ok(()); - } - - // Phase 2: Text indexing - if let Ok(mut status_guard) = status.lock() { - *status_guard = IndexingStatus::RunningTextIndex { - files_processed: 0, - current_file: Some("Starting text indexing...".to_string()), - start_time: Instant::now(), - }; - } - - // Create status callback for text indexing - let status_clone_5 = status.clone(); - let text_status_callback = Box::new(move |file_status: &str| { - if let Ok(mut status_guard) = status_clone_5.lock() { - if let IndexingStatus::RunningTextIndex { ref mut current_file, .. } = *status_guard { - *current_file = Some(file_status.to_string()); - } - } - }); - - // Create progress callback for text indexing - let status_clone_6 = status.clone(); - let text_progress_callback = Box::new(move |current_index: usize| { - if let Ok(mut status_guard) = status_clone_6.lock() { - if let IndexingStatus::RunningTextIndex { ref mut files_processed, .. } = *status_guard { - *files_processed = current_index; - } - } - }); - - // Process text indexing - if let Err(e) = process_text_indexing(&conn_mutex, &stop_flag, suspend_flag, Some(text_status_callback), Some(text_progress_callback), config) { - return Err(format!("Failed to process text indexing: {}", e)); - } - - // Mark text indexing as complete - if let Ok(mut status_guard) = status.lock() { - if let IndexingStatus::RunningTextIndex { ref mut current_file, .. } = *status_guard { - *current_file = Some("Text indexing complete".to_string()); - } - } - - Ok(()) - } - - /// Validates configuration against stored values and returns validation results. - /// Critical configuration changes that require index recreation: - /// - hash_length: affects file hash computation, invalidates existing file metadata - /// - indexing_path: changes the scope of indexed files - /// - tokenize: changes FTS5 tokenization, invalidates text search index - fn validate_config(conn: &Connection, config: &Config, indexing_path: &str) -> Result>, String> { - // Critical configuration values that require index recreation - let hash_length = config.processing.hash_length.to_string(); - let tokenize = config.processing.tokenize.clone(); - let include_hidden = config.processing.include_hidden.to_string(); - let normalized_path = { - let path = std::path::Path::new(indexing_path) - .canonicalize() - .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)) - .to_string_lossy() - .to_string(); - // Remove Windows UNC prefix \\?\ - if path.starts_with("\\\\?\\") { - path[4..].to_string() - } else { - path - } - }; - - // Check stored configuration values - let mut stored_hash_length: Option = None; - let mut stored_indexing_path: Option = None; - let mut stored_tokenize: Option = None; - let mut stored_include_hidden: Option = None; - - if let Ok(mut stmt) = conn.prepare("SELECT key, value FROM config_validation WHERE key IN ('hash_length', 'indexing_path', 'tokenize', 'include_hidden')") { - if let Ok(rows) = stmt.query_map([], |row| { - let key: String = row.get(0)?; - let value: String = row.get(1)?; - Ok((key, value)) - }) { - for row in rows.flatten() { - match row.0.as_str() { - "hash_length" => stored_hash_length = Some(row.1), - "indexing_path" => stored_indexing_path = Some(row.1), - "tokenize" => stored_tokenize = Some(row.1), - "include_hidden" => stored_include_hidden = Some(row.1), - _ => {} - } - } - } - } - - // Check if configuration is invalid - let hash_length_changed = stored_hash_length.as_ref().map_or(false, |stored| stored != &hash_length); - let indexing_path_changed = stored_indexing_path.as_ref().map_or(false, |stored| stored != &normalized_path); - let tokenize_changed = stored_tokenize.as_ref().map_or(false, |stored| stored != &tokenize); - let include_hidden_changed = stored_include_hidden.as_ref().map_or(false, |stored| stored != &include_hidden); - - if hash_length_changed || indexing_path_changed || tokenize_changed || include_hidden_changed { - let mut changes = Vec::new(); - if hash_length_changed { - changes.push(format!("hash_length: {} -> {}", - stored_hash_length.unwrap_or_else(|| "unknown".to_string()), hash_length)); - } - if indexing_path_changed { - changes.push(format!("indexing_path: {} -> {}", - stored_indexing_path.unwrap_or_else(|| "unknown".to_string()), normalized_path)); - } - if tokenize_changed { - changes.push(format!("tokenize: {} -> {}", - stored_tokenize.unwrap_or_else(|| "unknown".to_string()), tokenize)); - } - if include_hidden_changed { - changes.push(format!( - "include_hidden: {} -> {}", - stored_include_hidden.unwrap_or_else(|| "unknown".to_string()), - include_hidden - )); - } - - return Ok(Some(changes)); - } - - // No configuration changes detected - Ok(None) - } - - - /// Updates stored configuration values without clearing the index - fn update_config(conn: &Connection, config: &Config, indexing_path: &str) -> Result<(), String> { - let hash_length = config.processing.hash_length.to_string(); - let tokenize = config.processing.tokenize.clone(); - let include_hidden = config.processing.include_hidden.to_string(); - let normalized_path = { - let path = std::path::Path::new(indexing_path) - .canonicalize() - .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)) - .to_string_lossy() - .to_string(); - // Remove Windows UNC prefix \\?\ - if path.starts_with("\\\\?\\") { - path[4..].to_string() - } else { - path - } - }; - - // Update stored configuration values - conn.execute( - "INSERT OR REPLACE INTO config_validation (key, value) VALUES ('hash_length', ?1)", - params![hash_length], - ).map_err(|e| format!("Failed to store hash_length config: {}", e))?; - - conn.execute( - "INSERT OR REPLACE INTO config_validation (key, value) VALUES ('indexing_path', ?1)", - params![normalized_path], - ).map_err(|e| format!("Failed to store indexing_path config: {}", e))?; - - conn.execute( - "INSERT OR REPLACE INTO config_validation (key, value) VALUES ('tokenize', ?1)", - params![tokenize], - ).map_err(|e| format!("Failed to store tokenize config: {}", e))?; - - conn.execute( - "INSERT OR REPLACE INTO config_validation (key, value) VALUES ('include_hidden', ?1)", - params![include_hidden], - ).map_err(|e| format!("Failed to store include_hidden config: {}", e))?; - - Ok(()) - } -} - -impl Drop for IndexingService { - fn drop(&mut self) { - // Ensure graceful shutdown when the service is dropped - let _ = self.stop_indexing(); - } -} - -impl Default for IndexingService { - fn default() -> Self { - Self::new() - } -} - +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; +use std::collections::HashSet; +use rusqlite::{params, Connection, OptionalExtension}; + +use crate::extract::Registry; +use crate::file_handling::{ + cleanup_stale_index_entries, + count_tree_entries_fast, + extract_one_batch, + extract_scope_prepare, + fts_finalize_after_text_indexing, + load_existing_files, + 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, WorkerStats}; +use crate::db; + +/// Where one root's pipeline is in its life cycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RootPhase { + /// The parallel walk is discovering and writing file metadata. + Walking, + /// The walk finished; content extraction is draining this root's + /// pending rows. + Extracting, + Done, +} + +/// Progress for one indexing root. Each root runs its own walker and its +/// own extraction cursor; the GUI shows one row per root. +#[derive(Debug, Clone)] +pub struct RootProgress { + pub root: String, + pub phase: RootPhase, + /// Files the walk has seen so far. + pub walked: usize, + /// Concurrent `find`-based denominator; `None` until the count lands. + pub walk_total: Option, + /// Rows with searchable text: extracted in earlier runs plus this one. + pub extracted: usize, + /// The root's whole searchable set: pending + already-extracted rows + /// at the moment the walk finished. + pub extract_total: usize, + pub current_file: Option, + /// Walker threads busy right now / pool size. + pub active_workers: usize, + pub total_workers: usize, +} + +#[derive(Debug, Clone)] +pub enum IndexingStatus { + Idle, + Running { + start_time: Instant, + roots: Vec, + }, + Stopping, + Error(String), +} + +/// One setting whose stored (index-build-time) value differs from the +/// current config. Values that hold lists (roots, patterns, extensions) +/// are newline-joined — display them as multi-line columns, not inline. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigChange { + pub key: String, + /// What the index was built with. + pub stored: String, + /// What the config says now. + pub current: String, +} + +#[derive(Debug, Clone)] +pub enum IndexingCommand { + Start { + /// One or more directory roots to index. Order determines walk order; + /// duplicates are silently dropped at run time. + paths: Vec, + db_path: String, + config: Config, + }, + Stop, +} + +#[derive(Debug)] +pub struct IndexingService { + status: Arc>, + command_tx: mpsc::Sender, + db_connection: Arc>>>>, + suspend_flag: Arc, + _handle: thread::JoinHandle<()>, +} + +/// Polling interval for `should_abort` while suspended. +const SUSPEND_POLL_MS: u64 = 100; + +/// Combined stop/suspend check used by worker loops. Returns `true` iff the +/// 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 { + loop { + if *stop.lock().unwrap() { + return true; + } + if !suspend.load(Ordering::Relaxed) { + return false; + } + thread::sleep(Duration::from_millis(SUSPEND_POLL_MS)); + } +} + +/// Set process priority for background operation +// fn set_background_priority() { +// #[cfg(windows)] +// { +// use std::os::windows::raw::HANDLE; + +// // Windows implementation +// extern "system" { +// fn GetCurrentProcess() -> HANDLE; +// fn SetPriorityClass(hprocess: HANDLE, dwpriorityclass: u32) -> i32; +// } + +// const BELOW_NORMAL_PRIORITY_CLASS: u32 = 0x00004000; +// unsafe { +// SetPriorityClass(GetCurrentProcess(), BELOW_NORMAL_PRIORITY_CLASS); +// } +// } + +// #[cfg(unix)] +// { +// // Unix implementation +// use std::os::unix::process::CommandExt; +// unsafe { +// libc::nice(10); // Lower priority +// } +// } +// } + +/// Flips an [`AtomicBool`] when dropped. Held by `run_indexing` so the +/// per-root count subprocesses die on every exit path of a run. +struct CancelOnDrop(Arc); + +impl Drop for CancelOnDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} + +/// One root's in-flight indexing state, owned by the writer loop. +struct RootPipeline { + root: String, + walk: ParallelWalk, + stats: WorkerStats, + /// Concurrent `find` count; 0 = not yet known. + count_total: Arc, + pending_updates: Vec, + pending_inserts: Vec, + walked: usize, + walk_clean: bool, + phase: RootPhase, + extract: Option, + extract_total: usize, + extracted: usize, + current_file: Option, +} + +impl IndexingService { + pub fn new() -> Self { + let status = Arc::new(Mutex::new(IndexingStatus::Idle)); + let (command_tx, command_rx) = mpsc::channel(); + let db_connection = Arc::new(Mutex::new(None)); + let suspend_flag = Arc::new(AtomicBool::new(false)); + + let status_clone = status.clone(); + let db_connection_clone = db_connection.clone(); + let suspend_clone = suspend_flag.clone(); + let handle = thread::spawn(move || { + Self::indexing_thread(status_clone, command_rx, db_connection_clone, suspend_clone); + }); + + IndexingService { + status, + command_tx, + db_connection, + suspend_flag, + _handle: handle, + } + } + + /// Pause the indexer. All worker loops that call [`should_abort`] will + /// block until [`resume`](Self::resume) is called. No-op if already + /// suspended. Does not stop the worker — stop_indexing is still the way + /// to abort. + pub fn suspend(&self) { + self.suspend_flag.store(true, Ordering::Relaxed); + } + + /// Resume indexing after [`suspend`](Self::suspend). No-op if not + /// suspended. + pub fn resume(&self) { + self.suspend_flag.store(false, Ordering::Relaxed); + } + + pub fn is_suspended(&self) -> bool { + self.suspend_flag.load(Ordering::Relaxed) + } + + /// Start indexing one or more roots. Paths are walked in order; duplicate + /// or nested roots are de-duplicated by the indexer. At least one path is + /// required. + pub fn start_indexing( + &self, + paths: Vec, + db_path: String, + config: Config, + ) -> Result<(), String> { + if paths.is_empty() { + return Err("start_indexing requires at least one path".into()); + } + self.command_tx + .send(IndexingCommand::Start { paths, db_path, config }) + .map_err(|e| format!("Failed to send start command: {}", e)) + } + + /// Signal a running index pass to stop without waiting for it. Used + /// on shutdown paths that must stay responsive — the worker notices + /// the flag between batches and WAL makes an unflushed exit safe. + pub fn request_stop(&self) { + let _ = self.command_tx.send(IndexingCommand::Stop); + } + + pub fn stop_indexing(&self) -> Result<(), String> { + // First send the stop command + self.command_tx + .send(IndexingCommand::Stop) + .map_err(|e| format!("Failed to send stop command: {}", e))?; + + // Wait for indexing to transition to stopping state + let mut attempts = 0; + while attempts < 50 { // Wait up to 5 seconds + match self.get_status() { + IndexingStatus::Stopping => break, + IndexingStatus::Idle => return Ok(()), // Already stopped + IndexingStatus::Error(_) => return Ok(()), // Consider error state as stopped + _ => { + std::thread::sleep(std::time::Duration::from_millis(100)); + attempts += 1; + } + } + } + + // Flush the WAL and release the shared connection. WAL mode itself + // stays on — it's the persistent journal mode for the index. + if let Ok(mut db_opt) = self.db_connection.lock() { + if let Some(db_conn_arc) = db_opt.take() { + if let Ok(conn) = db_conn_arc.lock() { + let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", ()); + } + } + } + + Ok(()) + } + + pub fn get_status(&self) -> IndexingStatus { + self.status.lock().unwrap().clone() + } + + /// Force graceful shutdown - used for signal handling + pub fn graceful_shutdown(&self) -> Result<(), String> { + self.stop_indexing() + } + + /// Check if configuration changes require index recreation. A pure + /// *read* check: opens the existing index without ever wiping it (the + /// old `open_or_recreate` here could destroy the index before the user + /// 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> { + match db::open_existing(db_path, false) { + Ok(conn) => Self::validate_config(&conn, config, indexing_path), + Err(_) => Ok(None), + } + } + + /// Stop indexing and delete the database file for a clean rebuild + pub fn delete_index_for_rebuild(&self, db_path: &str) -> Result<(), String> { + // Stop any running indexing first + self.stop_indexing() + .map_err(|e| format!("Failed to stop indexing: {}", e))?; + + // Wait for indexing to actually stop + let mut attempts = 0; + while attempts < 50 { // Wait up to 5 seconds + match self.get_status() { + IndexingStatus::Idle => break, + IndexingStatus::Stopping | IndexingStatus::Running { .. } => { + std::thread::sleep(std::time::Duration::from_millis(100)); + attempts += 1; + } + IndexingStatus::Error(_) => break, // Consider error state as stopped + } + } + + // Delete the database file and its WAL sidecars. + if std::path::Path::new(db_path).exists() { + std::fs::remove_file(db_path) + .map_err(|e| format!("Failed to delete database file: {}", e))?; + } + for suffix in ["-wal", "-shm", "-journal"] { + let _ = std::fs::remove_file(format!("{}{}", db_path, suffix)); + } + + Ok(()) + } + + fn indexing_thread( + status: Arc>, + command_rx: mpsc::Receiver, + db_connection: Arc>>>>, + suspend_flag: Arc, + ) { + let stop_flag = Arc::new(Mutex::new(false)); + let mut indexing_handle: Option> = None; + + while let Ok(command) = command_rx.recv() { + match command { + IndexingCommand::Start { paths, db_path, config } => { + if matches!(*status.lock().unwrap(), IndexingStatus::Running { .. }) { + continue; // Already running + } + + // Join any previous indexing thread + if let Some(handle) = indexing_handle.take() { + let _ = handle.join(); + } + + *stop_flag.lock().unwrap() = false; + // One placeholder row per root so the GUI has structure + // to draw before the writer loop publishes real numbers. + *status.lock().unwrap() = IndexingStatus::Running { + start_time: Instant::now(), + roots: paths + .iter() + .map(|p| RootProgress { + root: p.clone(), + phase: RootPhase::Walking, + walked: 0, + walk_total: None, + extracted: 0, + extract_total: 0, + current_file: Some("Starting…".to_string()), + active_workers: 0, + total_workers: 0, + }) + .collect(), + }; + + // Run indexing in a separate thread + let status_clone = status.clone(); + let stop_flag_clone = stop_flag.clone(); + let paths_owned = paths.clone(); + let db_path_owned = db_path.clone(); + let config_owned = config.clone(); + + let db_connection_clone = db_connection.clone(); + let suspend_clone = suspend_flag.clone(); + indexing_handle = Some(thread::spawn(move || { + if let Err(e) = Self::run_indexing(&status_clone, &paths_owned, &db_path_owned, &stop_flag_clone, &suspend_clone, &config_owned, &db_connection_clone) { + *status_clone.lock().unwrap() = IndexingStatus::Error(e); + } else { + // Only set to Idle if we weren't stopped + if !*stop_flag_clone.lock().unwrap() { + *status_clone.lock().unwrap() = IndexingStatus::Idle; + } + } + + // Clear the database connection when indexing completes + if let Ok(mut db_opt) = db_connection_clone.lock() { + *db_opt = None; + } + })); + } + IndexingCommand::Stop => { + if matches!(*status.lock().unwrap(), IndexingStatus::Running { .. }) { + *status.lock().unwrap() = IndexingStatus::Stopping; + *stop_flag.lock().unwrap() = true; + } + } + } + } + + // Clean up any remaining indexing thread + if let Some(handle) = indexing_handle { + let _ = handle.join(); + } + } + + fn run_indexing( + status: &Arc>, + paths: &[String], + db_path: &str, + stop_flag: &Arc>, + suspend_flag: &Arc, + config: &Config, + db_connection: &Arc>>>>, + ) -> Result<(), String> { + if paths.is_empty() { + return Err("run_indexing: no paths provided".into()); + } + + // De-duplicate while preserving order. Roots are canonicalized first + // so `/home/jeremy` and `/home/jeremy/` (or a symlink to either) + // collapse to one walk. Pure nested-root deduplication (skip a root + // that is a prefix of an already-walked root) is handled by the + // per-file `seen_paths` set below. + let mut seen_roots = HashSet::new(); + let roots: Vec = paths + .iter() + .map(|p| { + std::path::Path::new(p) + .canonicalize() + .ok() + // Same spelling rules as `files.path`: a hand-rolled + // four-character strip turns `\\?\UNC\server\share` into + // `UNC\server\share`, which is not a path — and no longer + // looks like a share, so the root would silently walk with + // the local thread count instead of the network one. + .map(|c| path_to_db_string(&c)) + .unwrap_or_else(|| p.clone()) + }) + .filter(|p| seen_roots.insert(p.clone())) + .collect(); + + // Open and migrate the database to the current schema version. + let conn = db::open_or_recreate(db_path, &config.processing.tokenize)?; + + // Update configuration (for new installations or when no validation issues). + // `indexing_path` in the validation table stores the joined list so + // adding/removing a root triggers the same rebuild prompt as changing + // the legacy single path did. + Self::update_config(&conn, config, &roots.join("\n"))?; + + // Load existing files from database for incremental indexing + // Shared read-only with the walk threads, which classify against it. + let existing_files = Arc::new({ + let conn_ref = &conn; + load_existing_files(conn_ref) + .map_err(|e| format!("Failed to load existing files: {}", e))? + }); + + 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()); + } + + let run_start = Instant::now(); + + // Per-root concurrent counts, killed the moment this run exits by + // any path — the guard flips the token on drop and the count + // threads' subprocesses die within one poll interval. + let count_cancel = Arc::new(AtomicBool::new(false)); + let _count_guard = CancelOnDrop(count_cancel.clone()); + + // Shared with every root's walk workers, which use it to finish small + // text files without handing them to the content pass below. + let registry = Arc::new(Registry::default_set()); + let quantum = config.processing.batch_size.max(1); + + // One pipeline per root: its own walker (with per-root worker + // count), its own count thread, its own buffers and extraction + // cursor. They all funnel into this single writer thread. + let mut pipelines: Vec = Vec::with_capacity(roots.len()); + for root in &roots { + let ignore = crate::config::IgnoreSet::compile(&config.indexing.ignore_patterns) + .map_err(|e| format!("ignore patterns: {}", e))?; + let workers = config + .indexing + .root_workers + .get(root) + .copied() + .filter(|w| *w > 0) + .unwrap_or_else(|| thread_count_for(std::slice::from_ref(root))) + .clamp(1, 64); + let walk = walk_indexable_files( + std::slice::from_ref(root), + config.indexing.follow_symlinks, + config.indexing.include_hidden, + ignore, + existing_files.clone(), + config.clone(), + registry.clone(), + stop_flag.clone(), + suspend_flag.clone(), + workers, + ); + let stats = walk.worker_stats(); + + let count_total = Arc::new(AtomicUsize::new(0)); + { + let root = root.clone(); + let cancel = count_cancel.clone(); + let total = count_total.clone(); + let _ = thread::Builder::new().name("qs-count".into()).spawn(move || { + 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 { + root: root.clone(), + walk, + stats, + count_total, + pending_updates: Vec::new(), + pending_inserts: Vec::new(), + walked: 0, + walk_clean: true, + phase: RootPhase::Walking, + extract: None, + extract_total: 0, + extracted: 0, + current_file: None, + }); + } + + // Publish a status snapshot. Never clobbers Stopping — the command + // thread owns that transition. + let publish = |pipelines: &[RootPipeline]| { + let roots: Vec = pipelines + .iter() + .map(|p| RootProgress { + root: p.root.clone(), + phase: p.phase, + walked: p.walked, + walk_total: match p.count_total.load(Ordering::Relaxed) { + 0 => None, + n => Some(n), + }, + extracted: p.extracted, + extract_total: p.extract_total, + current_file: p.current_file.clone(), + active_workers: p.stats.active(), + total_workers: p.stats.total(), + }) + .collect(); + if let Ok(mut g) = status.lock() { + if !matches!(*g, IndexingStatus::Stopping) { + *g = IndexingStatus::Running { start_time: run_start, roots }; + } + } + }; + publish(&pipelines); + + // Round-robin with skipping: each round takes at most one quantum + // from every root that has work ready. Write-bottlenecked, all + // active roots get even quanta; read-bottlenecked, roots with + // empty channels are skipped and the firehose roots get the + // writer's full attention. + let mut seen_paths: HashSet = HashSet::new(); + let mut aborted = false; + let mut stale_cleanup_ok = true; + let mut cleanup_done = false; + let mut stale_deleted = 0usize; + let mut rr = 0usize; + + loop { + if should_abort(stop_flag, suspend_flag) { + aborted = true; + break; + } + let mut progressed = false; + let n = pipelines.len(); + for k in 0..n { + let p = &mut pipelines[(rr + k) % n]; + match p.phase { + RootPhase::Walking => { + let mut took = 0usize; + while took < quantum { + match p.walk.try_next() { + TryNext::Item(file) => { + took += 1; + p.walked += 1; + if p.walked % 64 == 0 { + p.current_file = Some(file.path.clone()); + } + // Membership decides what survives stale + // cleanup: "the walk saw this", never + // "processed successfully". Also dedupes + // symlinked spellings across roots. + if !seen_paths.insert(file.path.clone()) { + continue; + } + let Some(rec) = file.record else { continue }; + if file.action == FileIndexAction::Update { + p.pending_updates.push(rec); + if p.pending_updates.len() >= quantum { + process_batch_updates( + &conn_mutex, + &p.pending_updates, + stop_flag, + config, + )?; + p.pending_updates.clear(); + } + } else { + p.pending_inserts.push(rec); + if p.pending_inserts.len() >= quantum { + process_batch_inserts( + &conn_mutex, + &p.pending_inserts, + stop_flag, + config, + )?; + p.pending_inserts.clear(); + } + } + } + TryNext::Empty => break, + TryNext::Finished => { + // Join before deciding anything: workers + // close the channel when they stop for + // *any* reason, so a panic and a finished + // walk look identical from here. + p.walk_clean = p.walk.finish(); + process_batch_updates( + &conn_mutex, + &p.pending_updates, + stop_flag, + config, + )?; + p.pending_updates.clear(); + process_batch_inserts( + &conn_mutex, + &p.pending_inserts, + stop_flag, + config, + )?; + p.pending_inserts.clear(); + + if !p.walk_clean { + crate::log_warn!( + "a walk worker for {} terminated abnormally; \ + skipping stale cleanup", + p.root + ); + stale_cleanup_ok = false; + p.phase = RootPhase::Done; + } else if *stop_flag.lock().unwrap() { + p.phase = RootPhase::Done; + } else { + let cursor = ExtractCursor::for_root(&p.root); + let scope = + extract_scope_prepare(&conn_mutex, &cursor, config)?; + // Progress counts the root's whole + // searchable set: files extracted in + // earlier runs start the counter, so + // an unchanged root shows "X of X" + // rather than "0 of 0". + p.extract_total = scope.pending + scope.already_done; + p.extracted = scope.already_done; + if scope.pending == 0 { + p.phase = RootPhase::Done; + } else { + p.extract = Some(cursor); + p.phase = RootPhase::Extracting; + } + } + progressed = true; + break; + } + } + } + progressed |= took > 0; + } + RootPhase::Extracting => { + let cursor = p.extract.as_mut().expect("extracting root has a cursor"); + let mut last_file: Option = None; + let processed = extract_one_batch( + &conn_mutex, + cursor, + ®istry, + config, + stop_flag, + suspend_flag, + &mut |name| last_file = Some(name.to_string()), + )?; + if last_file.is_some() { + p.current_file = last_file; + } + if processed == 0 { + p.extract = None; + p.phase = RootPhase::Done; + } else { + p.extracted += processed; + } + progressed = true; + } + RootPhase::Done => {} + } + } + rr = rr.wrapping_add(1); + + // Once every walk has ended, reconcile deletions — globally, + // because a file may be reachable through more than one root's + // symlinks. Runs at most once per run, on this writer thread. + if !cleanup_done && pipelines.iter().all(|p| p.phase != RootPhase::Walking) { + cleanup_done = true; + let stopped = *stop_flag.lock().unwrap(); + if stale_cleanup_ok && !stopped { + let stale_paths: Vec = existing_files + .keys() + .filter(|path| !seen_paths.contains(*path)) + .filter(|path| { + !pipelines.iter().any(|p| p.walk.unreadable().covers(path)) + }) + .cloned() + .collect(); + let unreadable_count: usize = pipelines + .iter() + .map(|p| p.walk.unreadable().paths().len()) + .sum(); + if unreadable_count > 0 { + crate::log_warn!( + "{} director{} could not be read; index entries beneath \ + them were kept rather than deleted", + unreadable_count, + if unreadable_count == 1 { "y" } else { "ies" } + ); + } + if !stale_paths.is_empty() { + if let Some(first) = pipelines.first_mut() { + first.current_file = + Some("Removing stale index entries…".to_string()); + } + stale_deleted = cleanup_stale_index_entries( + &conn_mutex, + stale_paths.as_slice(), + stop_flag, + suspend_flag, + )?; + } + } + progressed = true; + } + + publish(&pipelines); + + if pipelines.iter().all(|p| p.phase == RootPhase::Done) { + break; + } + if !progressed { + thread::sleep(Duration::from_millis(2)); + } + } + + if aborted { + // Buffered records are valid work — land them before leaving. + for p in &mut pipelines { + process_batch_updates(&conn_mutex, &p.pending_updates, stop_flag, config)?; + p.pending_updates.clear(); + process_batch_inserts(&conn_mutex, &p.pending_inserts, stop_flag, config)?; + p.pending_inserts.clear(); + } + if let Ok(mut status_guard) = status.lock() { + *status_guard = IndexingStatus::Idle; + } + // Deliberately no stale cleanup: a partial walk has a partial + // seen set, and deleting everything it did not reach would + // empty most of the index. + return Ok(()); + } + + // FTS housekeeping once per completed run (cheap if nothing changed). + let _ = stale_deleted; + { + let conn = conn_mutex.lock().unwrap(); + fts_finalize_after_text_indexing(&conn)?; + } + + // Stamp the successful run so the coordinator can schedule the next + // periodic reindex from it. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + if let Ok(conn) = conn_mutex.lock() { + let _ = crate::db::repo::set_last_full_index(&conn, now); + } + + Ok(()) + } + + /// 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)> { + let sorted_joined = |v: &[String]| { + let mut v: Vec = v.to_vec(); + v.sort(); + v.join("\n") + }; + vec![ + ("hash_length", config.processing.hash_length.to_string()), + // Hashes are only comparable within one algorithm, and + // duplicate detection groups purely by hash. Bump this string + // whenever the digest input changes so existing indexes are + // offered a rebuild instead of silently mixing schemes. + ("hash_algorithm", "size+head".to_string()), + ("indexing_path", normalize_root_string(indexing_path)), + ("tokenize", config.processing.tokenize.clone()), + ("include_hidden", config.indexing.include_hidden.to_string()), + ( + "ignore_patterns", + sorted_joined(&config.indexing.ignore_patterns), + ), + ( + "content_extensions", + sorted_joined(&config.indexing.content_extensions), + ), + ] + } + + /// Compare current config against the values stored in the index. + /// Returns `Some(changes)` when the index was built under settings + /// that no longer match — the caller offers a rebuild. A key absent + /// from the DB (older index) only counts as changed when the DB has + /// stored *any* validation state before. + fn validate_config( + conn: &Connection, + config: &Config, + indexing_path: &str, + ) -> Result>, String> { + let mut changes = Vec::new(); + for (key, current) in Self::config_validation_entries(config, indexing_path) { + let stored: Option = conn + .query_row( + "SELECT value FROM config_validation WHERE key = ?1", + params![key], + |r| r.get(0), + ) + .optional() + .map_err(|e| format!("read config_validation.{}: {}", key, e))?; + if let Some(stored) = stored { + if stored != current { + changes.push(ConfigChange { + key: key.to_string(), + stored, + current, + }); + } + } + } + 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> { + for (key, current) in Self::config_validation_entries(config, indexing_path) { + conn.execute( + "INSERT OR REPLACE INTO config_validation (key, value) VALUES (?1, ?2)", + params![key, current], + ) + .map_err(|e| format!("store config_validation.{}: {}", key, e))?; + } + Ok(()) + } +} + +/// Canonicalize a root string for storage/comparison, stripping the Windows +/// UNC prefix. Multi-root strings (newline-joined) fail canonicalize and +/// pass through verbatim, which still compares consistently. +fn normalize_root_string(indexing_path: &str) -> String { + let path = std::path::Path::new(indexing_path) + .canonicalize() + .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)); + path_to_db_string(&path) +} + +impl Drop for IndexingService { + fn drop(&mut self) { + // Ensure graceful shutdown when the service is dropped + let _ = self.stop_indexing(); + } +} + +impl Default for IndexingService { + fn default() -> Self { + Self::new() + } +} + diff --git a/crates/quicksearch-core/src/lib.rs b/crates/quicksearch-core/src/lib.rs index 453302b..88304b2 100644 --- a/crates/quicksearch-core/src/lib.rs +++ b/crates/quicksearch-core/src/lib.rs @@ -1,13 +1,18 @@ pub mod cli; pub mod config; +pub mod coordinator; pub mod db; pub mod document_extraction; pub mod extract; pub mod file_handling; +pub mod incremental; pub mod indexing; +pub mod log; pub mod mime; +pub mod platform; pub mod query; -pub mod search_sql; +pub mod search; pub mod shutdown; pub mod snippet; +pub mod walk; pub mod watcher; diff --git a/crates/quicksearch-core/src/log.rs b/crates/quicksearch-core/src/log.rs new file mode 100644 index 0000000..5f20665 --- /dev/null +++ b/crates/quicksearch-core/src/log.rs @@ -0,0 +1,241 @@ +//! The process log: every line that would go to the terminal, kept in +//! memory so a windowed run can show it. +//! +//! Launched from a desktop launcher — or on Windows, where the GUI binary is +//! built for the window subsystem and has no console at all — the process has +//! nowhere to print. The warnings the walker, indexer and watcher emit are +//! exactly the ones a user needs when something looks wrong, and they were +//! going nowhere. +//! +//! So background reporting goes through [`log_info!`] and [`log_warn!`] +//! instead of `println!`/`eprintln!`: each writes the same line to stderr +//! *and* appends it to a bounded ring the GUI's Logs tab reads. A terminal +//! run looks exactly as it did; a windowed run gains the tab. +//! +//! Command output — search hits from `quicksearch-cli`, usage text, the +//! errors a command exits with — is not logged. That is a program's answer to +//! what it was asked, not a background event, and it belongs on stdout. + +use std::collections::VecDeque; +use std::io::Write; +use std::sync::{LazyLock, Mutex, MutexGuard}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Lines retained before the oldest are dropped. +/// +/// A run over a tree full of unreadable files can log per file, so this is +/// bounded rather than complete: the newest few thousand lines are what +/// diagnosing anything actually needs, and the ring holds the count it threw +/// away so the tab can say so instead of quietly lying. +pub const CAPACITY: usize = 5_000; + +/// How loud a line is. The GUI colors by this; stderr gets the `Warning:` +/// prefix that the same messages carried when they were `eprintln!`s. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Level { + Info, + Warn, +} + +/// One recorded line. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogLine { + /// Unix seconds when it was recorded. + pub at: u64, + pub level: Level, + /// Exactly the text written to stderr, prefix included. + pub text: String, +} + +/// Record an informational line: `println!`-style formatting. +#[macro_export] +macro_rules! log_info { + ($($arg:tt)*) => { + $crate::log::record($crate::log::Level::Info, ::std::format!($($arg)*)) + }; +} + +/// Record a warning. The stored and printed text gains a `Warning: ` prefix, +/// so call sites pass the message alone. +#[macro_export] +macro_rules! log_warn { + ($($arg:tt)*) => { + $crate::log::record($crate::log::Level::Warn, ::std::format!($($arg)*)) + }; +} + +/// Write `message` to stderr and to the ring. +/// +/// Prefer the [`log_info!`] / [`log_warn!`] macros; this is what they call. +/// +/// A failed stderr write is ignored rather than propagated: `eprintln!` +/// *panics* when the handle is unwritable, which on a process launched +/// without stdio would take down whichever background thread happened to +/// report something. Losing the terminal copy is acceptable — that is +/// precisely the case where the in-memory copy is the one that matters. +pub fn record(level: Level, message: String) { + let text = match level { + Level::Warn => format!("Warning: {}", message), + Level::Info => message, + }; + let _ = writeln!(std::io::stderr(), "{}", text); + lock().push(level, text); +} + +/// Every retained line, oldest first. +pub fn snapshot() -> Vec { + lock().lines.iter().cloned().collect() +} + +/// How many lines have been recorded since the process started, including +/// ones since dropped. Only ever grows, so a poll of this is the cheap way +/// to ask "anything new?" without copying the ring. +pub fn recorded() -> u64 { + lock().recorded +} + +/// How many lines the ring has evicted since the last [`clear`]. +pub fn dropped() -> u64 { + lock().dropped +} + +/// Forget every retained line. [`recorded`] keeps counting. +pub fn clear() { + lock().clear(); +} + +static LOG: LazyLock> = LazyLock::new(|| Mutex::new(Ring::new(CAPACITY))); + +/// Logging must not turn one panic into a cascade of them: a thread that +/// died mid-push would otherwise poison the lock and take down every later +/// logger. The ring is a `VecDeque` of owned strings, so the worst a poisoned +/// guard can hold is a line that was half-added. +fn lock() -> MutexGuard<'static, Ring> { + LOG.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The bounded line buffer. Split from the global so it can be tested on its +/// own instance — every other test in the process shares the global one. +struct Ring { + lines: VecDeque, + capacity: usize, + recorded: u64, + dropped: u64, +} + +impl Ring { + fn new(capacity: usize) -> Ring { + Ring { + lines: VecDeque::new(), + capacity: capacity.max(1), + recorded: 0, + dropped: 0, + } + } + + fn push(&mut self, level: Level, text: String) { + while self.lines.len() >= self.capacity { + self.lines.pop_front(); + self.dropped += 1; + } + self.lines.push_back(LogLine { + at: now_unix(), + level, + text, + }); + self.recorded += 1; + } + + fn clear(&mut self) { + self.lines.clear(); + self.dropped = 0; + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn texts(ring: &Ring) -> Vec<&str> { + ring.lines.iter().map(|l| l.text.as_str()).collect() + } + + #[test] + fn lines_come_back_oldest_first() { + let mut ring = Ring::new(8); + ring.push(Level::Info, "one".into()); + ring.push(Level::Warn, "two".into()); + assert_eq!(texts(&ring), vec!["one", "two"]); + assert_eq!(ring.lines[1].level, Level::Warn); + assert_eq!(ring.recorded, 2); + assert_eq!(ring.dropped, 0); + } + + #[test] + fn the_oldest_lines_are_dropped_at_capacity() { + let mut ring = Ring::new(3); + for i in 0..5 { + ring.push(Level::Info, format!("line {}", i)); + } + assert_eq!( + texts(&ring), + vec!["line 2", "line 3", "line 4"], + "only the newest `capacity` lines survive" + ); + assert_eq!(ring.dropped, 2, "and the count of the lost ones is kept"); + assert_eq!(ring.recorded, 5, "recorded counts everything ever pushed"); + } + + /// A zero capacity would spin the eviction loop forever on the first + /// push; hand-configuring one is not possible today, but the ring should + /// not depend on that staying true. + #[test] + fn a_zero_capacity_still_holds_one_line() { + let mut ring = Ring::new(0); + ring.push(Level::Info, "kept".into()); + assert_eq!(texts(&ring), vec!["kept"]); + } + + #[test] + fn clearing_empties_the_ring_but_not_the_total() { + let mut ring = Ring::new(2); + for i in 0..4 { + ring.push(Level::Info, format!("line {}", i)); + } + ring.clear(); + assert!(ring.lines.is_empty()); + assert_eq!(ring.dropped, 0, "dropped counts against what is shown"); + assert_eq!(ring.recorded, 4, "the running total survives a clear"); + } + + /// Through the global: the macros must land in the snapshot, and a + /// warning must carry the prefix its terminal line has. Written to + /// tolerate lines from tests running in parallel in this process. + #[test] + fn recorded_lines_reach_the_snapshot() { + let before = recorded(); + crate::log_info!("test-marker info {}", 1); + crate::log_warn!("test-marker warn {}", 2); + assert!(recorded() >= before + 2); + + let lines = snapshot(); + let info = lines.iter().find(|l| l.text == "test-marker info 1"); + let warn = lines + .iter() + .find(|l| l.text == "Warning: test-marker warn 2"); + assert_eq!(info.map(|l| l.level), Some(Level::Info)); + assert_eq!( + warn.map(|l| l.level), + Some(Level::Warn), + "a warning is stored with the prefix it printed with" + ); + assert!(info.unwrap().at > 0, "timestamped when recorded"); + } +} diff --git a/crates/quicksearch-core/src/mime.rs b/crates/quicksearch-core/src/mime.rs index bd080d8..a998039 100644 --- a/crates/quicksearch-core/src/mime.rs +++ b/crates/quicksearch-core/src/mime.rs @@ -1,11 +1,16 @@ //! MIME type guessing and `FileType` bitmask classification. //! //! Two stages: -//! 1. [`guess_mime`] infers a MIME type from a path — extension first via +//! 1. [`guess_mime_from_head`] infers a MIME type — extension first via //! `mime_guess`, falling back to magic-byte sniffing via `infer` for files //! whose extension is missing or ambiguous. //! 2. [`mime_to_type`] maps a MIME string to a [`FileType`] bitmask so a single //! file can belong to multiple categories (e.g. a `.docx` is Document|Text). +//! +//! The magic bytes are always ones the caller already holds. Indexing reads +//! the head of every new or changed file to hash it, and those are the same +//! bytes `infer` wants, so there is no path-based variant that goes back to +//! disk for them — that was a second open/read/close per undetectable file. use std::path::Path; @@ -65,26 +70,72 @@ impl std::ops::BitOrAssign for FileType { } } -/// Guess a MIME type for a path on disk. +/// Extensions `mime_guess` gets wrong or does not know, and what they really +/// are. /// -/// Tries extension-based lookup via `mime_guess` first (cheap, no I/O). If -/// that returns nothing or a generic `application/octet-stream`, and the file -/// is readable, falls back to `infer` magic-byte detection (reads a small -/// prefix of the file). +/// Consulted *before* `mime_guess`, because for these the table is not a +/// fallback but a correction. Everything here is plain text that would +/// otherwise get no content indexing at all: /// -/// Returns `None` if no guess can be made. -pub fn guess_mime(path: &Path) -> Option { +/// - `.ps1`/`.psm1`/`.psd1` and `.url` are simply absent from `mime_guess`, +/// and `infer` only knows binary magic, so they end up with no MIME — and +/// [`crate::extract::Registry`] has no extractor to offer, so the file is +/// marked "not applicable". PowerShell is the most common script type on a +/// Windows machine. +/// - `.bat` maps to `application/x-msdownload`, i.e. an executable. It is a +/// text file, and the plaintext extractor rightly refuses the executable +/// type. (`.cmd` already resolves to `text/plain`; it is listed so the pair +/// cannot drift.) +/// +/// Platform-neutral on purpose: a `.ps1` copied to a Linux box should classify +/// the same way. +const EXTENSION_OVERRIDES: &[(&str, &str)] = &[ + ("bat", "text/plain"), + ("cmd", "text/plain"), + ("inf", "text/plain"), + ("ps1", "text/plain"), + ("psd1", "text/plain"), + ("psm1", "text/plain"), + ("url", "text/plain"), +]; + +/// Look up [`EXTENSION_OVERRIDES`] for `path`. Extension comparison is +/// ASCII-case-insensitive, which matters more on Windows where `REPORT.BAT` is +/// as common as the lowercase spelling. +fn extension_override(path: &Path) -> Option<&'static str> { + let ext = path.extension()?.to_str()?.to_ascii_lowercase(); + EXTENSION_OVERRIDES + .iter() + .find(|(e, _)| *e == ext) + .map(|(_, mime)| *mime) +} + +/// Infer a MIME type from a path plus the file's leading bytes. +/// +/// Extension first — an override table, then `mime_guess` — and magic bytes +/// only when those come up empty or say `application/octet-stream`. +/// +/// `head` is whatever the caller already read; indexing passes the same buffer +/// it hashes. It bounds magic-byte detection, so a caller that supplies fewer +/// than 262 bytes (`infer`'s longest signature) can get `None` where a longer +/// head would have matched. The indexer's `hash_length` defaults to 8 KiB — +/// exactly what `infer` itself reads from a path — so at default config this +/// is as good as opening the file, and strictly cheaper. +/// +/// A `None` result is a real answer, not a "don't know": the content pass +/// stores it and does not re-derive it (see +/// [`crate::file_handling::extract_and_store`]). +pub fn guess_mime_from_head(path: &Path, head: &[u8]) -> Option { + if let Some(m) = extension_override(path) { + return Some(m.to_string()); + } if let Some(g) = mime_guess::from_path(path).first() { let s = g.essence_str(); if !s.is_empty() && s != "application/octet-stream" { return Some(s.to_string()); } } - // Magic-byte fallback. `infer::get_from_path` handles errors by returning None. - if let Ok(Some(t)) = infer::get_from_path(path) { - return Some(t.mime_type().to_string()); - } - None + infer::get(head).map(|t| t.mime_type().to_string()) } /// Map a MIME string to a [`FileType`] bitmask. Ported from Baloo's @@ -131,7 +182,13 @@ pub fn mime_to_type(mime: &str) -> FileType { | "vnd.oasis.opendocument.presentation" => { t |= FileType::DOCUMENT | FileType::PRESENTATION; } - // Archives + // Outlook saved messages and compiled HTML help are documents; both + // are ordinary things to find in a Windows home directory. + "vnd.ms-outlook" | "vnd.ms-htmlhelp" => { + t |= FileType::DOCUMENT; + } + // Archives. The Windows installer/cabinet formats are containers in + // exactly the same sense as the rest of this list. "zip" | "x-tar" | "x-7z-compressed" @@ -142,7 +199,9 @@ pub fn mime_to_type(mime: &str) -> FileType { | "x-bzip2" | "x-xz" | "vnd.debian.binary-package" - | "x-rpm" => { + | "x-rpm" + | "vnd.ms-cab-compressed" + | "x-msi" => { t |= FileType::ARCHIVE; } // application/xml is structured text @@ -228,11 +287,137 @@ mod tests { assert_eq!(FileType::from_name("Weird"), FileType::EMPTY); } + /// Extension resolution happens before magic bytes are consulted, so an + /// empty head is enough to exercise it. #[test] fn guess_mime_by_extension() { use std::path::PathBuf; - assert_eq!(guess_mime(&PathBuf::from("a.txt")).as_deref(), Some("text/plain")); - assert_eq!(guess_mime(&PathBuf::from("a.png")).as_deref(), Some("image/png")); - assert_eq!(guess_mime(&PathBuf::from("a.mp3")).as_deref(), Some("audio/mpeg")); + let by_ext = |n: &str| guess_mime_from_head(&PathBuf::from(n), b"").unwrap_or_default(); + assert_eq!(by_ext("a.txt"), "text/plain"); + assert_eq!(by_ext("a.png"), "image/png"); + assert_eq!(by_ext("a.mp3"), "audio/mpeg"); + } + + /// Every override must land on a type the plaintext extractor accepts — + /// the point of the table is that these files get their contents indexed. + #[test] + fn windows_script_types_reach_the_plaintext_extractor() { + use crate::extract::{plaintext::PlaintextExtractor, Extractor}; + use std::path::PathBuf; + + for name in [ + "deploy.ps1", + "Module.psm1", + "Module.psd1", + "build.bat", + "build.cmd", + "driver.inf", + "bookmark.url", + ] { + let mime = guess_mime_from_head(&PathBuf::from(name), b"") + .unwrap_or_else(|| panic!("{} has no MIME", name)); + assert!( + PlaintextExtractor.supports(&mime), + "{} -> {} is not extractable as text", + name, + mime + ); + } + } + + #[test] + fn extension_overrides_are_case_insensitive() { + use std::path::PathBuf; + // Uppercase extensions are ordinary on Windows. + assert_eq!( + guess_mime_from_head(&PathBuf::from("DEPLOY.PS1"), b"").as_deref(), + Some("text/plain") + ); + assert_eq!( + guess_mime_from_head(&PathBuf::from("Build.Bat"), b"").as_deref(), + Some("text/plain") + ); + } + + /// The override table must win over the file's actual content: a `.ps1` + /// holding something `infer` would recognise is still a script. + #[test] + fn extension_overrides_beat_magic_bytes() { + use std::path::PathBuf; + assert_eq!( + guess_mime_from_head(&PathBuf::from("a.ps1"), b"Write-Host hi").as_deref(), + Some("text/plain") + ); + assert_eq!( + guess_mime_from_head(&PathBuf::from("a.ps1"), b"%PDF-1.7").as_deref(), + Some("text/plain") + ); + } + + #[test] + fn sql_dumps_are_extractable() { + use crate::extract::{plaintext::PlaintextExtractor, Extractor}; + use std::path::PathBuf; + let mime = guess_mime_from_head(&PathBuf::from("schema.sql"), b"").unwrap(); + assert!(PlaintextExtractor.supports(&mime), "{}", mime); + } + + /// The content pass trusts the MIME the walk stored, including `None`, and + /// never reopens the file to second-guess it. That is only sound if a + /// `hash_length`-sized head is enough to recognise a format from its magic + /// bytes — `infer`'s longest signature is 262 bytes and the default head is + /// 8 KiB, so it is by a wide margin. This pins that for extensionless + /// files, where magic bytes are the only signal there is. + #[test] + fn a_default_sized_head_is_enough_for_magic_byte_detection() { + use std::path::PathBuf; + 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"), + ("gif", b"GIF89a", "image/gif"), + ("pdf", b"%PDF-1.7", "application/pdf"), + ("zip", &[0x50, 0x4b, 0x03, 0x04], "application/zip"), + ("gz", &[0x1f, 0x8b, 0x08], "application/gzip"), + ]; + + for (tag, magic, expected) in samples { + // No extension at all, so nothing but the bytes can answer. + let path = PathBuf::from(format!("/tmp/qs-sniff-{}", tag)); + let mut body = magic.to_vec(); + body.resize(head_bytes, 0); + assert_eq!( + guess_mime_from_head(&path, &body).as_deref(), + Some(*expected), + "{} must be detectable from a default-sized head", + tag + ); + } + } + + /// The other side of that bound: starve the head below `infer`'s longest + /// signature and detection legitimately degrades. Documented behaviour of + /// a non-default `hash_length`, not a bug — but it must stay a `None` + /// rather than a wrong guess. + #[test] + fn a_head_shorter_than_the_signature_declines_rather_than_guessing() { + use std::path::PathBuf; + let path = PathBuf::from("/tmp/qs-sniff-truncated"); + assert_eq!(guess_mime_from_head(&path, b"").as_deref(), None); + assert_eq!(guess_mime_from_head(&path, &[0x89]).as_deref(), None); + // Enough bytes, and it resolves. + assert_eq!( + guess_mime_from_head(&path, &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]) + .as_deref(), + Some("image/png") + ); + } + + #[test] + fn windows_container_and_document_types_classify() { + assert!(mime_to_type("application/vnd.ms-cab-compressed").contains(FileType::ARCHIVE)); + assert!(mime_to_type("application/x-msi").contains(FileType::ARCHIVE)); + assert!(mime_to_type("application/vnd.ms-outlook").contains(FileType::DOCUMENT)); + assert!(mime_to_type("application/vnd.ms-htmlhelp").contains(FileType::DOCUMENT)); } } diff --git a/crates/quicksearch-core/src/platform.rs b/crates/quicksearch-core/src/platform.rs new file mode 100644 index 0000000..74b231a --- /dev/null +++ b/crates/quicksearch-core/src/platform.rs @@ -0,0 +1,421 @@ +//! The one place `#[cfg]` lives. +//! +//! Everything here answers "what does this platform do differently", so no +//! other module has to ask. Two rules keep it honest: +//! +//! - Every function is defined for every target. Callers never wrap a call +//! site in `#[cfg]`; if a platform has nothing to do, its arm is the +//! trivial one. +//! - Anything that can be decided from a string rather than a syscall is +//! split out and made testable everywhere ([`is_unc_string`], +//! [`PATH_COLLATION`]), because the test suite runs on Linux. + +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; + +/// The user's home directory. +/// +/// On Windows `%USERPROFILE%` is checked **first**. Git Bash and MSYS2 export +/// `HOME` as a POSIX path (`/c/Users/me`) that no Win32 API can open, and +/// preferring it would point the config file, the index, and the default +/// indexing root at a directory that does not exist. +pub fn home_dir() -> Option { + #[cfg(windows)] + { + if let Some(profile) = std::env::var_os("USERPROFILE") { + return Some(profile); + } + } + std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) +} + +/// Whether a directory entry counts as hidden. +/// +/// Unix: a leading dot. Windows: a leading dot **or** `FILE_ATTRIBUTE_HIDDEN` +/// / `FILE_ATTRIBUTE_SYSTEM` — without which `include_hidden = false` hides +/// nothing on Windows, and `$RECYCLE.BIN`, `System Volume Information`, +/// `pagefile.sys` and `AppData` all get indexed. +/// +/// `meta` is a closure because on Unix it is never called: the walkers +/// deliberately avoid `metadata()`, which would cost an extra `lstat` per +/// entry and a full round trip on a network share. On Windows the cost is +/// zero anyway — both `std::fs::DirEntry::metadata` and +/// `walkdir::DirEntry::metadata` hand back data already cached from +/// `FindNextFileW`. +pub fn entry_is_hidden(name: &str, meta: F) -> bool +where + F: FnOnce() -> Option, +{ + if name.starts_with('.') { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_HIDDEN, FILE_ATTRIBUTE_SYSTEM, + }; + if let Some(m) = meta() { + return m.file_attributes() & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM) != 0; + } + } + #[cfg(not(windows))] + { + let _ = meta; + } + false +} + +/// Whether `path` has a hidden component *below* the root that contains it. +/// +/// Components at or above a root are exempt, because the walkers exempt their +/// root too (depth 0 is always kept — users explicitly chose their roots). +/// The two must agree: if they disagree, a full run indexes a file that the +/// watcher then refuses to update, and the index churns on every cycle. +/// +/// That is a latent bug on Unix (`~/.config/app` as a root) and a certainty on +/// Windows, where `AppData` carries `FILE_ATTRIBUTE_HIDDEN` and +/// `std::env::temp_dir()` lives underneath it. +/// +/// `roots` are matched by whole path components, so `/a/bc` is not treated as +/// living under `/a/b`. A path under no known root is checked in full. +pub fn path_has_hidden_component_under(path: &Path, roots: &[PathBuf]) -> bool { + // Innermost containing root wins: with both `/data` and `/data/.cache` + // configured, a file under the latter is only judged below `.cache`. + let base = roots + .iter() + .filter(|r| path.starts_with(r)) + .max_by_key(|r| r.components().count()); + + let (mut current, tail) = match base { + Some(root) => match path.strip_prefix(root) { + Ok(tail) => (root.clone(), tail), + Err(_) => (PathBuf::new(), path), + }, + None => (PathBuf::new(), path), + }; + + // Rebuild the absolute path as we descend: a bare tail component cannot + // be stat'd on its own, and the attribute check needs a real path. + for component in tail.components() { + current.push(component); + if let Component::Normal(name) = component { + let name = name.to_string_lossy(); + if entry_is_hidden(&name, || std::fs::metadata(¤t).ok()) { + return true; + } + } + } + false +} + +/// Whether `s` names a UNC path, in either spelling. +/// +/// Split out from [`is_network_path`] so the string half is testable on every +/// platform, and written with explicit parentheses — the precedence of `&&` +/// against `||` is exactly the kind of thing that silently disables the +/// network thread pool. +/// +/// Only *called* on Windows; compiled everywhere so its tests run everywhere, +/// which is the point of splitting it out. +#[cfg_attr(not(windows), allow(dead_code))] +pub(crate) fn is_unc_string(s: &str) -> bool { + s.starts_with(r"\\?\UNC\") || (s.starts_with(r"\\") && !s.starts_with(r"\\?\")) +} + +/// 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", +]; + +/// Whether `path` lives on a network filesystem. +/// +/// Reads `/proc/mounts` and takes the longest mount point that is a prefix of +/// `path` — the innermost mount is the one that actually serves it. +#[cfg(target_os = "linux")] +pub(crate) fn is_network_path(path: &Path) -> bool { + let Ok(mounts) = std::fs::read_to_string("/proc/mounts") else { + return false; + }; + let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + + let mut best: Option<(usize, bool)> = None; + for line in mounts.lines() { + let mut fields = line.split_whitespace(); + let (Some(_dev), Some(point), Some(fstype)) = (fields.next(), fields.next(), fields.next()) + else { + continue; + }; + // `/proc/mounts` octal-escapes spaces and a few other characters. + let point = point.replace("\\040", " "); + let point = Path::new(&point); + if !target.starts_with(point) { + continue; + } + let depth = point.components().count(); + let is_network = NETWORK_FS_TYPES.contains(&fstype); + if best.is_none_or(|(d, _)| depth > d) { + best = Some((depth, is_network)); + } + } + best.is_some_and(|(_, is_network)| is_network) +} + +/// Whether `path` is served by a network redirector. +/// +/// UNC needs no syscall. A *mapped drive letter* does: `Z:\` backed by an SMB +/// share is indistinguishable from a local disk by string inspection, and it +/// is the common case — asking `GetDriveTypeW` is the only way to tell. Left +/// undetected it walks with `LOCAL_THREADS` instead of `NETWORK_THREADS`, +/// which is the exact failure the threading design exists to prevent. +#[cfg(windows)] +pub(crate) fn is_network_path(path: &Path) -> bool { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; + use windows_sys::Win32::System::WindowsProgramming::DRIVE_REMOTE; + + let s = path.to_string_lossy(); + if is_unc_string(&s) { + return true; + } + + // GetDriveTypeW wants a root ("Z:\"), not an arbitrary path. + let Some(root) = path.components().next() else { + return false; + }; + let Component::Prefix(prefix) = root else { + return false; + }; + let mut wide: Vec = prefix.as_os_str().encode_wide().collect(); + wide.push(b'\\' as u16); + wide.push(0); + unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_REMOTE } +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(crate) fn is_network_path(_path: &Path) -> bool { + false +} + +/// Whether the filesystem-notification backend covers a whole tree from one +/// watch on its root. +/// +/// `false` (inotify): one watch descriptor covers exactly one directory's +/// entries, so the caller must walk the tree and register every directory +/// itself — which is what lets it skip `.git`, `node_modules` and hidden +/// subtrees instead of spending a scarce descriptor on each. +/// +/// `true` (`ReadDirectoryChangesW`): one handle covers the subtree, and +/// directories created later are included automatically. Registering +/// per-directory here would be actively harmful rather than merely wasteful — +/// notify allocates a 16 KiB buffer *inline per watch* plus a directory +/// handle, so a large tree would ask for gigabytes of buffers and tens of +/// thousands of handles. The pruning moves to the event path instead. +/// +/// macOS FSEvents is also natively recursive, but it is left on the +/// per-directory path here because that path works there and is the one under +/// test. +pub const WATCH_ROOTS_RECURSIVELY: bool = cfg!(windows); + +/// SQLite collation for comparing stored path strings. +/// +/// Windows filesystems are case-insensitive, and SQLite's `LIKE` already folds +/// ASCII case by default. A path filter that compares one half with `=` and the +/// other with `LIKE` would otherwise disagree with itself. `NOCASE` folds ASCII +/// only, which matches what `LIKE` does — non-ASCII paths stay case-sensitive +/// on both sides, consistently. +pub const PATH_COLLATION: &str = if cfg!(windows) { "NOCASE" } else { "BINARY" }; + +/// How long to keep retrying a delete that fails because something else holds +/// the file open. +#[cfg(windows)] +const REMOVE_RETRY_BUDGET: std::time::Duration = std::time::Duration::from_millis(500); + +/// `fs::remove_file`, retried briefly on Windows. +/// +/// Unix `unlink` succeeds even with the file open, so this is a single call +/// there. Windows returns a sharing violation while *any* handle is open — +/// most often an antivirus scanner reading the file microseconds after we +/// closed it. The retry turns a spurious hard failure into a short pause. +pub fn remove_file_retrying(path: &Path) -> std::io::Result<()> { + #[cfg(not(windows))] + { + std::fs::remove_file(path) + } + #[cfg(windows)] + { + let deadline = std::time::Instant::now() + REMOVE_RETRY_BUDGET; + loop { + match std::fs::remove_file(path) { + Ok(()) => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(e), + Err(e) => { + if std::time::Instant::now() >= deadline { + return Err(e); + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } + } + } + } +} + +/// Deny read access to `dir`, for tests that exercise the unreadable-directory +/// guards. +/// +/// Exposed (hidden) rather than duplicated per test module because +/// `tests/full_index.rs` is a separate crate and needs it too. Windows uses +/// `icacls`: a deny ACE binds even the owner until the paired +/// [`restore_read`] rewrites it, and neither call needs elevation. +#[doc(hidden)] +pub fn deny_read(dir: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o000)) + } + #[cfg(windows)] + { + icacls(dir, &["/deny", &format!("{}:(OI)(CI)(RD)", current_user()?)]) + } + #[cfg(not(any(unix, windows)))] + { + let _ = dir; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "deny_read is not supported on this target", + )) + } +} + +/// Undo [`deny_read`] so the directory can be cleaned up. +#[doc(hidden)] +pub fn restore_read(dir: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)) + } + #[cfg(windows)] + { + icacls(dir, &["/remove:d", ¤t_user()?]) + } + #[cfg(not(any(unix, windows)))] + { + let _ = dir; + Ok(()) + } +} + +#[cfg(windows)] +fn current_user() -> std::io::Result { + match (std::env::var("USERDOMAIN"), std::env::var("USERNAME")) { + (Ok(domain), Ok(user)) => Ok(format!("{}\\{}", domain, user)), + (_, Ok(user)) => Ok(user), + _ => Err(std::io::Error::other("USERNAME is not set")), + } +} + +#[cfg(windows)] +fn icacls(dir: &Path, args: &[&str]) -> std::io::Result<()> { + let out = std::process::Command::new("icacls") + .arg(dir) + .args(args) + .output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!( + "icacls {}: {}", + dir.display(), + String::from_utf8_lossy(&out.stderr).trim() + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unc_spellings() { + assert!(is_unc_string(r"\\server\share")); + assert!(is_unc_string(r"\\server\share\dir\file.txt")); + assert!(is_unc_string(r"\\?\UNC\server\share")); + // A verbatim *drive* path is local, not a share. This is the case the + // original `&&`/`||` precedence got wrong. + assert!(!is_unc_string(r"\\?\C:\Users\me")); + assert!(!is_unc_string(r"C:\Users\me")); + assert!(!is_unc_string("/home/me")); + assert!(!is_unc_string("")); + } + + #[test] + 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" }); + } + + #[test] + fn dotfiles_are_hidden_without_consulting_metadata() { + let mut called = false; + assert!(entry_is_hidden(".git", || { + called = true; + None + })); + assert!(!called, "a dot prefix must short-circuit before any stat"); + } + + #[test] + fn ordinary_names_are_not_hidden() { + assert!(!entry_is_hidden("Documents", || None)); + assert!(!entry_is_hidden("report.txt", || None)); + } + + #[test] + fn hidden_components_are_measured_from_the_innermost_root() { + let root = PathBuf::from(format!("{}.config", sep_prefix())); + let roots = vec![root.clone()]; + + // 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)); + + // A dot *below* the root still counts. + assert!(path_has_hidden_component_under( + &root.join(".secret").join("x"), + &roots + )); + } + + #[test] + fn a_path_under_no_root_is_checked_in_full() { + let roots = vec![PathBuf::from(format!("{}srv", sep_prefix()))]; + let stray = PathBuf::from(format!("{}home{}me{}.ssh", sep_prefix(), SEP, SEP)); + assert!(path_has_hidden_component_under(&stray, &roots)); + } + + #[test] + fn sibling_roots_do_not_capture_each_other() { + // `/a/bc` does not live under `/a/b`, so the `.x` below it is judged, + // not exempted. + let roots = vec![PathBuf::from(format!("{}a{}b", sep_prefix(), SEP))]; + let other = PathBuf::from(format!("{}a{}bc{}.x", sep_prefix(), SEP, SEP)); + assert!(path_has_hidden_component_under(&other, &roots)); + } + + const SEP: char = std::path::MAIN_SEPARATOR; + + /// An absolute-path prefix for the running platform, so these tests read + /// the same on both. + fn sep_prefix() -> String { + if cfg!(windows) { + r"C:\".to_string() + } else { + "/".to_string() + } + } +} diff --git a/crates/quicksearch-core/src/query/lexer.rs b/crates/quicksearch-core/src/query/lexer.rs index 20beb3c..79751d1 100644 --- a/crates/quicksearch-core/src/query/lexer.rs +++ b/crates/quicksearch-core/src/query/lexer.rs @@ -32,7 +32,32 @@ impl std::fmt::Display for LexError { impl std::error::Error for LexError {} +/// Whether the `:` at `colon` is the one in a drive letter rather than a +/// property operator. +/// +/// True only when the word so far is exactly one ASCII letter *and* a path +/// separator follows, which is narrow enough to leave `12:30`, `a:b` and +/// `type:Audio` tokenizing exactly as before. +fn is_drive_letter_colon(bytes: &[u8], start: usize, colon: usize) -> bool { + colon == start + 1 + && bytes[start].is_ascii_alphabetic() + && matches!(bytes.get(colon + 1), Some(b'\\') | Some(b'/')) +} + pub fn tokenize(input: &str) -> Result, LexError> { + let (tokens, err) = tokenize_spanned(input); + match err { + Some(e) => Err(e), + None => Ok(tokens.into_iter().map(|(t, _)| t).collect()), + } +} + +/// [`tokenize`], but each token carries its byte range in `input`, and a +/// trailing error (unterminated quote, invalid UTF-8) is returned alongside +/// the tokens lexed before it instead of discarding them. `Quoted` spans +/// include both quote characters. This is what the GUI's syntax highlighter +/// runs on: it must color the intact prefix of a half-typed query. +pub fn tokenize_spanned(input: &str) -> (Vec<(Token, std::ops::Range)>, Option) { let bytes = input.as_bytes(); let mut i = 0usize; let mut out = Vec::new(); @@ -45,62 +70,72 @@ pub fn tokenize(input: &str) -> Result, LexError> { } match b { b'(' => { - out.push(Token::LParen); + out.push((Token::LParen, i..i + 1)); i += 1; } b')' => { - out.push(Token::RParen); + out.push((Token::RParen, i..i + 1)); i += 1; } b':' => { - out.push(Token::Op(Op::Contains)); + out.push((Token::Op(Op::Contains), i..i + 1)); i += 1; } b'=' => { - out.push(Token::Op(Op::Eq)); + out.push((Token::Op(Op::Eq), i..i + 1)); i += 1; } b'<' => { if bytes.get(i + 1) == Some(&b'=') { - out.push(Token::Op(Op::Le)); + out.push((Token::Op(Op::Le), i..i + 2)); i += 2; } else { - out.push(Token::Op(Op::Lt)); + out.push((Token::Op(Op::Lt), i..i + 1)); i += 1; } } b'>' => { if bytes.get(i + 1) == Some(&b'=') { - out.push(Token::Op(Op::Ge)); + out.push((Token::Op(Op::Ge), i..i + 2)); i += 2; } else { - out.push(Token::Op(Op::Gt)); + out.push((Token::Op(Op::Gt), i..i + 1)); i += 1; } } b'"' => { // Double-quoted phrase. Supports doubled-quote escape `""`. + // + // Copied as a UTF-8 slice, not byte by byte: `bytes[j] as char` + // decodes Latin-1, so `"José"` came back as `José` and matched + // nothing. Quoting is also how people write paths containing + // spaces, which makes this the more visible of the two. let mut j = i + 1; let mut buf = String::new(); + let mut segment_start = j; while j < bytes.len() { if bytes[j] == b'"' { + buf.push_str(&input[segment_start..j]); if bytes.get(j + 1) == Some(&b'"') { buf.push('"'); j += 2; + segment_start = j; continue; } break; } - buf.push(bytes[j] as char); j += 1; } if j >= bytes.len() { - return Err(LexError { - message: "unterminated quoted phrase".into(), - offset: i, - }); + return ( + out, + Some(LexError { + message: "unterminated quoted phrase".into(), + offset: i, + }), + ); } - out.push(Token::Quoted(buf)); + out.push((Token::Quoted(buf), i..j + 1)); i = j + 1; } _ => { @@ -108,6 +143,14 @@ pub fn tokenize(input: &str) -> Result, LexError> { let start = i; while i < bytes.len() { let c = bytes[i]; + if c == b':' && is_drive_letter_colon(bytes, start, i) { + // `C:\Users\me` is one word, not `C` `:` `\Users\me`. + // Without this, `path:C:\Users\me` parses as the + // filter `path` = `C` and silently matches nothing — + // the first thing a Windows user types. + i += 1; + continue; + } if c.is_ascii_whitespace() || matches!(c, b'(' | b')' | b':' | b'=' | b'<' | b'>' | b'"') { @@ -115,22 +158,29 @@ pub fn tokenize(input: &str) -> Result, LexError> { } i += 1; } - let word = std::str::from_utf8(&bytes[start..i]) - .map_err(|e| LexError { - message: format!("invalid UTF-8 in word: {}", e), - offset: start, - })? - .to_string(); - match word.as_str() { - "AND" => out.push(Token::And), - "OR" => out.push(Token::Or), - _ => out.push(Token::Word(word)), - } + let word = match std::str::from_utf8(&bytes[start..i]) { + Ok(w) => w.to_string(), + Err(e) => { + return ( + out, + Some(LexError { + message: format!("invalid UTF-8 in word: {}", e), + offset: start, + }), + ) + } + }; + let tok = match word.as_str() { + "AND" => Token::And, + "OR" => Token::Or, + _ => Token::Word(word), + }; + out.push((tok, start..i)); } } } - Ok(out) + (out, None) } #[cfg(test)] @@ -211,4 +261,153 @@ mod tests { fn unterminated_quote_is_error() { assert!(tokenize(r#""oops"#).is_err()); } + + #[test] + fn non_ascii_survives_a_quoted_phrase() { + // Byte-wise copying decoded this as Latin-1 (`José`), so the phrase + // never matched anything. + let t = tokenize(r#""C:\Users\José\docs""#).unwrap(); + assert_eq!(t, vec![Token::Quoted(r"C:\Users\José\docs".into())]); + + // ...including around a doubled-quote escape, which splits the copy. + let t = tokenize(r#""ü""ö""#).unwrap(); + assert_eq!(t, vec![Token::Quoted(r#"ü"ö"#.into())]); + } + + #[test] + fn a_drive_letter_colon_does_not_split_the_word() { + let t = tokenize(r"path:C:\Users\me\docs").unwrap(); + assert_eq!( + t, + vec![ + Token::Word("path".into()), + Token::Op(Op::Contains), + Token::Word(r"C:\Users\me\docs".into()), + ] + ); + + // Forward slashes are equally valid on Windows. + let t = tokenize("path:D:/data").unwrap(); + assert_eq!( + t, + vec![ + Token::Word("path".into()), + Token::Op(Op::Contains), + Token::Word("D:/data".into()), + ] + ); + } + + #[test] + fn spans_cover_every_token_shape() { + let input = r#"type:Audio "a b" (x) size<=5"#; + let (toks, err) = tokenize_spanned(input); + assert!(err.is_none()); + let spanned: Vec<(&str, Token)> = toks + .iter() + .map(|(t, r)| (&input[r.clone()], t.clone())) + .collect(); + assert_eq!( + spanned, + vec![ + ("type", Token::Word("type".into())), + (":", Token::Op(Op::Contains)), + ("Audio", Token::Word("Audio".into())), + (r#""a b""#, Token::Quoted("a b".into())), + ("(", Token::LParen), + ("x", Token::Word("x".into())), + (")", Token::RParen), + ("size", Token::Word("size".into())), + ("<=", Token::Op(Op::Le)), + ("5", Token::Word("5".into())), + ] + ); + } + + #[test] + fn quoted_span_includes_quotes_and_escapes() { + let input = r#"x "a""b" y"#; + let (toks, err) = tokenize_spanned(input); + assert!(err.is_none()); + assert_eq!(toks[1].0, Token::Quoted(r#"a"b"#.into())); + assert_eq!(&input[toks[1].1.clone()], r#""a""b""#); + } + + #[test] + fn spans_are_byte_offsets_around_non_ascii() { + let input = r#"José "café" naïve"#; + let (toks, err) = tokenize_spanned(input); + assert!(err.is_none()); + assert_eq!(&input[toks[0].1.clone()], "José"); + assert_eq!(&input[toks[1].1.clone()], r#""café""#); + assert_eq!(&input[toks[2].1.clone()], "naïve"); + assert_eq!(toks[2].1.end, input.len()); + } + + #[test] + fn unterminated_quote_keeps_prefix_tokens() { + let (toks, err) = tokenize_spanned(r#"type:Audio "oops"#); + let err = err.expect("should report the unterminated quote"); + assert_eq!(err.offset, 11, "offset of the opening quote"); + assert_eq!( + toks.iter().map(|(t, _)| t.clone()).collect::>(), + vec![ + Token::Word("type".into()), + Token::Op(Op::Contains), + Token::Word("Audio".into()), + ] + ); + } + + #[test] + fn stars_stay_inside_words() { + let (toks, err) = tokenize_spanned("foo*bar *"); + assert!(err.is_none()); + assert_eq!( + toks.iter().map(|(t, _)| t.clone()).collect::>(), + vec![Token::Word("foo*bar".into()), Token::Word("*".into())] + ); + } + + #[test] + fn tokenize_matches_span_stripped_tokenize_spanned() { + for input in ["a AND (b:c)", r#""q" x>=2"#, "path:C:\\U foo*"] { + let plain = tokenize(input).unwrap(); + let (spanned, err) = tokenize_spanned(input); + assert!(err.is_none()); + let stripped: Vec = spanned.into_iter().map(|(t, _)| t).collect(); + assert_eq!(plain, stripped, "input {:?}", input); + } + } + + #[test] + fn the_drive_letter_rule_stays_narrow() { + // Two digits before the colon: still a time, not a drive. + assert_eq!( + tokenize("12:30").unwrap(), + vec![ + Token::Word("12".into()), + Token::Op(Op::Contains), + Token::Word("30".into()), + ] + ); + // One letter, but no separator after the colon. + assert_eq!( + tokenize("a:b").unwrap(), + vec![ + Token::Word("a".into()), + Token::Op(Op::Contains), + Token::Word("b".into()), + ] + ); + // A separator, but the key is longer than one character. + assert_eq!( + tokenize("type:/Audio").unwrap(), + vec![ + Token::Word("type".into()), + Token::Op(Op::Contains), + Token::Word("/Audio".into()), + ] + ); + } } diff --git a/crates/quicksearch-core/src/query/mod.rs b/crates/quicksearch-core/src/query/mod.rs index 4a6d3bd..66596dc 100644 --- a/crates/quicksearch-core/src/query/mod.rs +++ b/crates/quicksearch-core/src/query/mod.rs @@ -22,7 +22,12 @@ pub mod ast; pub mod lexer; pub mod parser; +pub mod pattern; +pub mod split; pub mod translator; pub use ast::{Op, Term}; -pub use translator::{parse_and_build, SqlQuery}; +pub use lexer::tokenize_spanned; +pub use pattern::{RegexQuery, TermPattern}; +pub use split::{split_for_cascade, CascadeQuery}; +pub use translator::{parse_and_build, SqlQuery, TranslateError}; diff --git a/crates/quicksearch-core/src/query/pattern.rs b/crates/quicksearch-core/src/query/pattern.rs new file mode 100644 index 0000000..0adeb64 --- /dev/null +++ b/crates/quicksearch-core/src/query/pattern.rs @@ -0,0 +1,533 @@ +//! Compiled matchers for the cascade term and the `regex:` keyword. +//! +//! A term with an unquoted `*` compiles to a small regex (every literal +//! chunk escaped, stars joined with `.*`), so wildcards and `regex:` share +//! one linear-time matching engine. Terms without stars stay on the +//! [`Literal`](TermPattern::Literal) path, which reproduces the cascade's +//! original `==`/`find`/`count_occurrences` semantics byte for byte. +//! +//! `.` never matches `\n`, so a star cannot span lines of extracted text — +//! a `*` bridging a whole document would produce absurd match ranges and +//! page-sized snippets. Names and paths contain no newlines, so the rule +//! only shows up in content matching. + +use std::ops::Range; + +use regex::{Regex, RegexBuilder}; + +use super::translator::TranslateError; +use crate::snippet; + +/// Compile-time memory cap for user-supplied and derived regexes. Keeps a +/// hostile pattern (`a{1000000}{1000}` and friends) from ballooning the +/// compiled program; matching itself is linear-time by construction. +const REGEX_SIZE_LIMIT: usize = 4 << 20; + +/// Occurrence counts saturate here, matching `count_frac` in the cascade. +const COUNT_CAP: usize = 1000; + +/// The cascade term, compiled once at split time. +#[derive(Debug, Clone, Default)] +pub enum TermPattern { + /// No matchable content: an empty term, or only stars (`*`, `**`). + /// Matches nothing — a bare `*` must not become a scan of everything. + #[default] + Empty, + /// A star-free term. Kept as plain string operations, not a regex, so + /// existing queries keep their exact semantics (and speed). + Literal(LiteralPattern), + /// A term with at least one active wildcard. + Wildcard(WildcardPattern), +} + +#[derive(Debug, Clone)] +pub struct LiteralPattern { + text: String, + folded: String, +} + +#[derive(Debug, Clone)] +pub struct WildcardPattern { + /// Literal chunks between stars, in order. Never empty, and no chunk + /// is empty: edge stars are folded into the compiled regexes, doubled + /// stars collapse. + segments: Vec, + /// Unanchored search regexes with non-greedy joins — leftmost-shortest + /// match, which is what a snippet window wants. + search_cs: Regex, + search_ci: Regex, + /// Anchored (`^…$`) regexes for whole-field matching (rank tiers 1/2). + anchored_cs: Regex, + anchored_ci: Regex, +} + +/// One piece of the search phrase as split out of the token stream. +/// `glob` is true only for plain unquoted words — quoted phrases and +/// reassembled `key:value` text keep their stars literal. +#[derive(Debug, Clone)] +pub struct TermPart { + pub text: String, + pub glob: bool, +} + +/// A chunk stream: literal text interleaved with active stars. +enum Chunk { + Lit(String), + Star, +} + +impl TermPattern { + /// Compile the joined term parts. Parts are joined with a single space, + /// exactly like the display term (`parts.join(" ")`). + pub fn build(parts: &[TermPart]) -> Result { + let mut chunks: Vec = Vec::new(); + let push_lit = |chunks: &mut Vec, s: &str| { + if s.is_empty() { + return; + } + if let Some(Chunk::Lit(prev)) = chunks.last_mut() { + prev.push_str(s); + } else { + chunks.push(Chunk::Lit(s.to_string())); + } + }; + for (idx, part) in parts.iter().enumerate() { + if idx > 0 { + push_lit(&mut chunks, " "); + } + if part.glob { + let mut first = true; + for piece in part.text.split('*') { + if !first && !matches!(chunks.last(), Some(Chunk::Star)) { + chunks.push(Chunk::Star); + } + first = false; + push_lit(&mut chunks, piece); + } + } else { + push_lit(&mut chunks, &part.text); + } + } + + let leading = matches!(chunks.first(), Some(Chunk::Star)); + let trailing = chunks.len() > 1 && matches!(chunks.last(), Some(Chunk::Star)); + let has_star = chunks.iter().any(|c| matches!(c, Chunk::Star)); + let segments: Vec = chunks + .into_iter() + .filter_map(|c| match c { + Chunk::Lit(s) => Some(s), + Chunk::Star => None, + }) + .collect(); + + if segments.is_empty() { + // "" or stars only. + return Ok(TermPattern::Empty); + } + if !has_star { + let text = segments.into_iter().next().unwrap(); + let folded = text.to_ascii_lowercase(); + return Ok(TermPattern::Literal(LiteralPattern { text, folded })); + } + + let escaped: Vec = segments.iter().map(|s| regex::escape(s)).collect(); + let compile = |src: &str, ci: bool| -> Result { + RegexBuilder::new(src) + .case_insensitive(ci) + .size_limit(REGEX_SIZE_LIMIT) + .build() + .map_err(|e| TranslateError::BadRegex(e.to_string())) + }; + // Edge stars are dropped from the search form — under substring + // semantics a leading/trailing `.*?` adds nothing. + let search_src = escaped.join(".*?"); + // The anchored form keeps them: `*foo` must whole-match "myfoo". + let anchored_src = format!( + "^{}{}{}$", + if leading { ".*" } else { "" }, + escaped.join(".*"), + if trailing { ".*" } else { "" }, + ); + Ok(TermPattern::Wildcard(WildcardPattern { + search_cs: compile(&search_src, false)?, + search_ci: compile(&search_src, true)?, + anchored_cs: compile(&anchored_src, false)?, + anchored_ci: compile(&anchored_src, true)?, + segments, + })) + } + + pub fn is_empty(&self) -> bool { + matches!(self, TermPattern::Empty) + } + + pub fn is_wildcard(&self) -> bool { + matches!(self, TermPattern::Wildcard(_)) + } + + /// The literal text, when the term has no wildcard. SQL builders branch + /// on this to keep the original single-`LIKE`/phrase-`MATCH` shapes. + pub fn literal(&self) -> Option<&str> { + match self { + TermPattern::Literal(l) => Some(&l.text), + _ => None, + } + } + + /// Literal chunks between wildcards (the whole term when literal). + pub fn segments(&self) -> &[String] { + match self { + TermPattern::Empty => &[], + TermPattern::Literal(l) => std::slice::from_ref(&l.text), + TermPattern::Wildcard(w) => &w.segments, + } + } + + /// Characters of literal (non-star) content — the trigram floor and + /// path-tier switch count these. + pub fn literal_char_count(&self) -> usize { + self.segments().iter().map(|s| s.chars().count()).sum() + } + + /// Does the pattern match the entire field? + pub fn whole_match(&self, text: &str, case_insensitive: bool) -> bool { + match self { + TermPattern::Empty => false, + TermPattern::Literal(l) => { + if case_insensitive { + text.eq_ignore_ascii_case(&l.text) + } else { + text == l.text + } + } + TermPattern::Wildcard(w) => { + let re = if case_insensitive { + &w.anchored_ci + } else { + &w.anchored_cs + }; + re.is_match(text) + } + } + } + + /// Leftmost match as a byte range. Literal folding is ASCII-only and + /// byte-length preserving, so folded offsets are valid in the original — + /// the same invariant the cascade has always relied on. + pub fn find_first(&self, text: &str, case_insensitive: bool) -> Option> { + match self { + TermPattern::Empty => None, + TermPattern::Literal(l) => { + let pos = if case_insensitive { + text.to_ascii_lowercase().find(&l.folded)? + } else { + text.find(&l.text)? + }; + Some(pos..pos + l.text.len()) + } + TermPattern::Wildcard(w) => { + let re = if case_insensitive { + &w.search_ci + } else { + &w.search_cs + }; + re.find(text).map(|m| m.range()) + } + } + } + + /// Non-overlapping occurrence count, capped at 1000 (the cascade's + /// `count_frac` saturates there anyway). + 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::Wildcard(w) => { + let re = if case_insensitive { + &w.search_ci + } else { + &w.search_cs + }; + re.find_iter(text).take(COUNT_CAP).count() + } + } + } +} + +/// A compiled `regex:` query. Case-insensitive by default (override with an +/// inline `(?-i:…)`); `multi_line` makes `^`/`$` per-line over extracted +/// text, which is what they mean in a search box. +#[derive(Debug, Clone)] +pub struct RegexQuery { + pub source: String, + re: Regex, +} + +impl RegexQuery { + pub fn new(source: &str) -> Result { + let re = RegexBuilder::new(source) + .case_insensitive(true) + .multi_line(true) + .size_limit(REGEX_SIZE_LIMIT) + .build() + .map_err(|e| TranslateError::BadRegex(e.to_string()))?; + // The regex analog of the bare-`*` rule, but loud: the user typed an + // explicit keyword, so tell them instead of matching every file. + if re.is_match("") { + return Err(TranslateError::BadRegex(format!( + "'{}' can match the empty string and would match every file", + source + ))); + } + Ok(RegexQuery { + source: source.to_string(), + re, + }) + } + + pub fn is_match(&self, text: &str) -> bool { + self.re.is_match(text) + } + + pub fn find_first(&self, text: &str) -> Option> { + self.re.find(text).map(|m| m.range()) + } + + /// Non-overlapping occurrence count, capped at 1000. + pub fn count(&self, text: &str) -> usize { + self.re.find_iter(text).take(COUNT_CAP).count() + } +} + +/// Cap a match range at `max_len` bytes (aligned back to a char boundary) +/// before handing it to `snippet::window_around`. A greedy user regex can +/// legitimately match megabytes of a minified file; the snippet window +/// wants the start of that, not all of it. +pub fn clamp_match_range(text: &str, range: Range, max_len: usize) -> Range { + let mut end = range.end.min(range.start + max_len); + while end > range.start && !text.is_char_boundary(end) { + end -= 1; + } + range.start..end +} + +#[cfg(test)] +mod tests { + use super::*; + + fn part(text: &str, glob: bool) -> TermPart { + TermPart { + text: text.into(), + glob, + } + } + + fn wildcard(parts: &[TermPart]) -> WildcardPattern { + match TermPattern::build(parts).unwrap() { + TermPattern::Wildcard(w) => w, + other => panic!("expected wildcard, got {:?}", other), + } + } + + #[test] + fn starless_parts_build_a_literal() { + let p = TermPattern::build(&[part("hello", false), part("world", true)]).unwrap(); + assert_eq!(p.literal(), Some("hello world")); + assert!(!p.is_wildcard()); + } + + #[test] + fn empty_and_star_only_terms_match_nothing() { + for parts in [ + vec![], + vec![part("", false)], + vec![part("*", true)], + vec![part("**", true)], + ] { + let p = TermPattern::build(&parts).unwrap(); + assert!(p.is_empty(), "{:?}", parts); + assert!(!p.whole_match("anything", true)); + assert!(p.find_first("anything", true).is_none()); + assert_eq!(p.count("anything", true), 0); + } + } + + #[test] + fn quoted_star_stays_literal() { + // A quoted "*" arrives with glob = false. + let p = TermPattern::build(&[part("a*b", false)]).unwrap(); + assert_eq!(p.literal(), Some("a*b")); + assert!(p.find_first("xa*by", false).is_some()); + assert!(p.find_first("aXb", false).is_none()); + } + + #[test] + fn segment_shapes() { + // Edge stars vanish into the anchors: `*foo` whole-matches any + // suffix `foo`, `foo*` any prefix. + let p = TermPattern::build(&[part("*foo", true)]).unwrap(); + assert_eq!(p.segments(), ["foo"]); + assert!(p.whole_match("myfoo", false)); + assert!(!p.whole_match("foomy", false)); + + let p = TermPattern::build(&[part("foo*", true)]).unwrap(); + assert_eq!(p.segments(), ["foo"]); + assert!(p.whole_match("foomy", false)); + assert!(!p.whole_match("myfoo", false)); + + let w = wildcard(&[part("f*o*o", true)]); + assert_eq!(w.segments, ["f", "o", "o"]); + + // Doubled stars collapse. + let w = wildcard(&[part("f**o", true)]); + assert_eq!(w.segments, ["f", "o"]); + + // The implicit joining space is literal content. + let w = wildcard(&[part("a*", true), part("b", false)]); + assert_eq!(w.segments, ["a", " b"]); + + // `* *` — the joining space between two stars is interior literal + // content, so this is a real (if odd) pattern, not Empty. + let p = TermPattern::build(&[part("*", true), part("*", true)]).unwrap(); + assert_eq!(p.segments(), [" "]); + assert!(p.whole_match("a b", false)); + assert!(!p.whole_match("ab", false)); + + let p = TermPattern::build(&[part("*x", true), part("y*", true)]).unwrap(); + assert_eq!(p.segments(), ["x y"]); + assert!(p.whole_match("ax yb", false)); + } + + #[test] + fn whole_match_uses_anchors() { + let p = TermPattern::build(&[part("*.txt", true)]).unwrap(); + assert!(p.whole_match("notes.txt", false)); + assert!(p.whole_match("NOTES.TXT", true)); + assert!(!p.whole_match("NOTES.TXT", false)); + assert!(!p.whole_match("notes.txt.bak", false)); + + let p = TermPattern::build(&[part("rep*rt", true)]).unwrap(); + assert!(p.whole_match("report", false)); + assert!(!p.whole_match("report2024", false)); + } + + #[test] + fn find_first_is_leftmost_shortest() { + let p = TermPattern::build(&[part("a*b", true)]).unwrap(); + // Leftmost-first with a lazy join: starts at 0, ends at the first b. + assert_eq!(p.find_first("aXXbYYb", false), Some(0..4)); + // Case-insensitive variant. + assert_eq!(p.find_first("AXXB", true), Some(0..4)); + assert_eq!(p.find_first("AXXB", false), None); + } + + #[test] + fn star_does_not_cross_newlines() { + let p = TermPattern::build(&[part("foo*bar", true)]).unwrap(); + assert!(p.find_first("foo bar", false).is_some()); + assert!(p.find_first("foo\nbar", false).is_none()); + } + + #[test] + fn utf8_boundaries_in_segments_and_haystacks() { + let p = TermPattern::build(&[part("café*menu", true)]).unwrap(); + let hay = "le café du menu"; + let r = p.find_first(hay, false).unwrap(); + assert_eq!(&hay[r], "café du menu"); + // Case-insensitive over non-ASCII haystack: offsets stay valid. + let hay = "LE CAFÉ DU MENU"; + let r = p.find_first(hay, true).unwrap(); + assert!(hay.is_char_boundary(r.start) && hay.is_char_boundary(r.end)); + } + + #[test] + fn count_is_nonoverlapping_and_capped() { + let p = TermPattern::build(&[part("a*b", true)]).unwrap(); + assert_eq!(p.count("ab ab ab", false), 3); + let many = "ab ".repeat(2000); + assert_eq!(p.count(&many, false), 1000); + } + + #[test] + fn literal_parity_with_string_ops() { + let p = TermPattern::build(&[part("Report", false)]).unwrap(); + assert!(p.whole_match("Report", false)); + assert!(!p.whole_match("report", false)); + assert!(p.whole_match("report", true)); + assert_eq!(p.find_first("my Report.pdf", false), Some(3..9)); + assert_eq!(p.find_first("my report.pdf", true), Some(3..9)); + assert_eq!(p.find_first("my report.pdf", false), None); + assert_eq!(p.count("report Report", false), 1); + assert_eq!(p.count("report Report", true), 2); + } + + #[test] + fn regex_defaults_case_insensitive_with_optout() { + let r = RegexQuery::new("foo\\d+").unwrap(); + assert!(r.is_match("FOO123")); + let r = RegexQuery::new("(?-i:FOO)\\d+").unwrap(); + assert!(r.is_match("FOO1")); + assert!(!r.is_match("foo1")); + } + + #[test] + fn regex_multiline_anchors() { + let r = RegexQuery::new("^total:").unwrap(); + assert!(r.is_match("line one\ntotal: 5")); + } + + #[test] + fn invalid_regex_is_an_error_not_a_panic() { + for src in ["[", "(", "a{2,1}", "(?P<)"] { + assert!( + matches!(RegexQuery::new(src), Err(TranslateError::BadRegex(_))), + "{:?}", + src + ); + } + } + + #[test] + fn empty_matchable_regexes_are_rejected() { + for src in ["", ".*", "a*", "x|", "()", "(a+)*"] { + assert!( + matches!(RegexQuery::new(src), Err(TranslateError::BadRegex(_))), + "{:?} should be rejected", + src + ); + } + } + + #[test] + fn hostile_regexes_fail_fast_or_run_linear() { + // Deep nesting: rejected cleanly by the parser's nest limit. + assert!(RegexQuery::new(&"(".repeat(2000)).is_err()); + // Huge counted repetition: rejected by size_limit, not compiled. + assert!(RegexQuery::new("a{1000000}{1000}").is_err()); + // Classic backtracking bomb: the linear engine answers immediately + // (a backtracker would take exponential time here). + let r = RegexQuery::new("(a+)+$").unwrap(); + let hay = format!("{}b", "a".repeat(10_000)); + assert!(!r.is_match(&hay)); + } + + #[test] + fn find_and_count_on_regex() { + let r = RegexQuery::new("b[aeiou]d").unwrap(); + let hay = "bad bed bodkin"; + assert_eq!(r.find_first(hay), Some(0..3)); + assert_eq!(r.count(hay), 3); + } + + #[test] + fn clamp_respects_char_boundaries() { + let text = "aééééb"; + let r = clamp_match_range(text, 0..text.len(), 4); + assert!(text.is_char_boundary(r.end)); + assert!(r.end <= 4); + // No-op when already short enough. + assert_eq!(clamp_match_range(text, 1..3, 100), 1..3); + } +} diff --git a/crates/quicksearch-core/src/query/split.rs b/crates/quicksearch-core/src/query/split.rs new file mode 100644 index 0000000..1625779 --- /dev/null +++ b/crates/quicksearch-core/src/query/split.rs @@ -0,0 +1,457 @@ +//! Split raw search-box input into (cascade term, structured filters). +//! +//! The ranked search cascade has no boolean logic by design: everything +//! that isn't a recognized `key:value` filter joins the *term* — the single +//! phrase the cascade matches through its filename/full-text/fuzzy stages. +//! Recognized filters (`type:`, `modified:`, `path:`, `mime:`, `name:`) +//! become parameterized SQL fragments ANDed onto every cascade stage. +//! +//! Robustness rules for search-as-you-type: +//! - A lex error (e.g. a half-typed quote) degrades to "whole input is the +//! term" — incremental typing must never surface an error. +//! - An *unrecognized* `key:value` (like `12:30`) is reassembled verbatim +//! into the term. +//! - A recognized key whose value doesn't translate (bad date, unknown type +//! name) is a real [`TranslateError`] — the caller shows it inline. +//! - `AND`/`OR`/parens are not operators here; the words pass through into +//! the term, parens are dropped. + +use super::ast::Op; +use super::lexer::{tokenize, Token}; +use super::pattern::{RegexQuery, TermPart, TermPattern}; +use super::translator::{build_filter, is_filter_key, TranslateError}; + +/// The cascade's parsed input: one term string plus composable filter SQL. +#[derive(Debug, Clone, Default)] +pub struct CascadeQuery { + /// The ranked search phrase; may be empty when the input was + /// filter-only or regex-only. + pub term: String, + /// `term` compiled for matching: literal, wildcard, or empty. + pub pattern: TermPattern, + /// A `regex:` filter, matched in Rust against name, path and content — + /// never part of the SQL. + pub regex: Option, + /// Zero or more ` AND (...)` fragments with anonymous `?` placeholders + /// over alias `f`; appended verbatim to every stage's WHERE clause. + pub filter_sql: String, + pub filter_params: Vec, +} + +impl CascadeQuery { + /// Nothing to rank on: no term pattern and no regex. (Filters alone + /// don't drive a search.) + pub fn is_empty(&self) -> bool { + self.pattern.is_empty() && self.regex.is_none() + } + + fn term_only(term: &str) -> CascadeQuery { + let term = term.trim().to_string(); + // Un-lexable input is searched verbatim — stars are not wildcards + // here, mirroring the "whole input is the term" degrade rule. + let pattern = TermPattern::build(&[TermPart { + text: term.clone(), + glob: false, + }]) + .expect("literal patterns always compile"); + CascadeQuery { + term, + pattern, + ..CascadeQuery::default() + } + } +} + +fn op_str(op: Op) -> &'static str { + match op { + Op::Contains => ":", + Op::Eq => "=", + Op::Lt => "<", + Op::Le => "<=", + Op::Gt => ">", + Op::Ge => ">=", + } +} + +pub fn split_for_cascade(input: &str) -> Result { + // NUL bytes can't occur in filenames or extracted text, but they do + // break SQLite text binding and the FTS5 query parser — strip them. + let input = input.replace('\0', ""); + let input = input.as_str(); + let tokens = match tokenize(input) { + Ok(t) => t, + // Half-typed input (unterminated quote, invalid word): the whole + // raw string is the term. Never an error mid-keystroke. + Err(_) => return Ok(CascadeQuery::term_only(input)), + }; + + let mut out = CascadeQuery::default(); + let mut term_parts: Vec = Vec::new(); + let mut i = 0usize; + // Only plain unquoted words are wildcard-eligible; everything else + // (quoted phrases, demoted AND/OR, reassembled key:value glue) is + // searched verbatim. + let literal = |text: &str| TermPart { + text: text.to_string(), + glob: false, + }; + + while i < tokens.len() { + match &tokens[i] { + Token::Word(word) => { + // Candidate filter: Word(key) Op [Op] (Word|Quoted). + // The lexer emits `modified:>=x` as Word Op(:) Op(>=) Word. + if let Some(Token::Op(op1)) = tokens.get(i + 1) { + let (op, value_idx) = match tokens.get(i + 2) { + Some(Token::Op(op2)) => (*op2, i + 3), + _ => (*op1, i + 2), + }; + let value = match tokens.get(value_idx) { + Some(Token::Word(v)) | Some(Token::Quoted(v)) => Some(v.clone()), + _ => None, + }; + 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(_))); + if word.eq_ignore_ascii_case("regex") { + // Not a SQL filter: compiled here, matched in + // Rust against name, path and content. + if op != Op::Contains { + return Err(TranslateError::UnsupportedOp { + key: word.clone(), + op, + }); + } + if out.regex.is_some() { + return Err(TranslateError::BadRegex( + "only one regex: per query".into(), + )); + } + out.regex = Some(RegexQuery::new(&value)?); + i = value_idx + 1; + continue; + } + if is_filter_key(word) { + let frag = build_filter(word, op, &value, value_is_word)?; + out.filter_sql.push_str(" AND ("); + out.filter_sql.push_str(&frag.sql); + out.filter_sql.push(')'); + out.filter_params.extend(frag.params); + i = value_idx + 1; + continue; + } + // Unrecognized key — reassemble verbatim (`12:30`, + // `foo:bar`), gluing any further `:value` chains + // (`foo:bar:baz`). + let mut glued = format!("{}{}{}", word, op_str(op), value); + i = value_idx + 1; + 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) + { + glued.push_str(v); + i += 1; + } + } + term_parts.push(literal(&glued)); + continue; + } + // Key + op with no value yet (mid-typing "type:"): + // pass through as literal text. + term_parts.push(literal(&format!("{}{}", word, op_str(*op1)))); + i += 2; + continue; + } + term_parts.push(TermPart { + text: word.clone(), + glob: word.contains('*'), + }); + } + Token::Quoted(q) => term_parts.push(literal(q)), + // Not operators in the cascade grammar — plain words. + Token::And => term_parts.push(literal("AND")), + Token::Or => term_parts.push(literal("OR")), + // Grouping has no meaning without boolean logic. + Token::LParen | Token::RParen => {} + // Dangling operator (e.g. "a > b" typed literally). + Token::Op(op) => term_parts.push(literal(op_str(*op))), + } + i += 1; + } + + out.term = term_parts + .iter() + .map(|p| p.text.as_str()) + .collect::>() + .join(" "); + out.pattern = TermPattern::build(&term_parts)?; + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::types::Value; + + #[test] + fn plain_words_join_in_order() { + let q = split_for_cascade("hello brave world").unwrap(); + assert_eq!(q.term, "hello brave world"); + assert!(q.filter_sql.is_empty()); + assert!(q.filter_params.is_empty()); + } + + #[test] + fn empty_input() { + let q = split_for_cascade("").unwrap(); + assert_eq!(q.term, ""); + assert!(q.filter_sql.is_empty()); + } + + #[test] + fn each_recognized_filter_key_extracts() { + for input in [ + "type:Audio", + "modified:>=2024-01-01", + "mtime:<2023-12-01", + "path:/home/me", + "folder:/home/me", + "includefolder:/home/me", + "name:report", + "filename:report", + "mime:application/pdf", + ] { + let q = split_for_cascade(input).unwrap(); + assert_eq!(q.term, "", "input {:?} should be pure filter", input); + assert!( + q.filter_sql.starts_with(" AND ("), + "input {:?} → {:?}", + input, + q.filter_sql + ); + assert!(!q.filter_params.is_empty(), "input {:?}", input); + } + } + + #[test] + fn filters_and_term_mix() { + let q = split_for_cascade("type:Document budget report modified:>=2024-01-01").unwrap(); + assert_eq!(q.term, "budget report"); + assert_eq!(q.filter_sql.matches(" AND (").count(), 2); + assert_eq!(q.filter_params.len(), 2); // type bits + mtime bound + } + + #[test] + fn unknown_key_stays_literal() { + let q = split_for_cascade("meeting 12:30 notes").unwrap(); + assert_eq!(q.term, "meeting 12:30 notes"); + assert!(q.filter_sql.is_empty()); + } + + #[test] + fn unknown_key_chain_reassembles() { + let q = split_for_cascade("foo:bar:baz").unwrap(); + assert_eq!(q.term, "foo:bar:baz"); + } + + #[test] + fn half_typed_quote_is_whole_term() { + let q = split_for_cascade("\"unclosed phrase").unwrap(); + assert_eq!(q.term, "\"unclosed phrase"); + assert!(q.filter_sql.is_empty()); + } + + #[test] + fn half_typed_filter_key_is_literal() { + let q = split_for_cascade("type:").unwrap(); + assert_eq!(q.term, "type:"); + assert!(q.filter_sql.is_empty()); + } + + #[test] + fn recognized_key_bad_value_errors() { + assert!(matches!( + split_for_cascade("modified:>=not-a-date"), + Err(TranslateError::BadDate(_)) + )); + assert!(matches!( + split_for_cascade("type:NotAThing"), + Err(TranslateError::UnknownProperty(_)) + )); + } + + #[test] + fn and_or_parens_are_plain_text() { + let q = split_for_cascade("(alpha AND beta) OR gamma").unwrap(); + assert_eq!(q.term, "alpha AND beta OR gamma"); + } + + /// The end-to-end shape of the bug: before the lexer fix this produced + /// the filter `parent = "C"` plus a junk term, and returned nothing. + #[test] + 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); + } + + #[test] + fn quoted_value_for_filter() { + let q = split_for_cascade("path:\"/home/me/My Documents\"").unwrap(); + assert_eq!(q.term, ""); + assert_eq!( + q.filter_params[0], + Value::Text("/home/me/My Documents".into()) + ); + } + + #[test] + fn quoted_phrase_joins_term() { + let q = split_for_cascade("\"exact phrase\" extra").unwrap(); + assert_eq!(q.term, "exact phrase extra"); + } + + #[test] + fn injection_shapes_stay_bound() { + // Everything lands either in the term (never interpolated into + // SQL by the cascade — bound as parameters there too) or in + // filter_params. filter_sql must never contain user text. + let q = split_for_cascade("mime:application/x-foo'; DROP TABLE files; --").unwrap(); + assert!(!q.filter_sql.contains("DROP"), "{}", q.filter_sql); + // The value went into params (term got the trailing junk words). + assert!(matches!(&q.filter_params[0], Value::Text(t) if t.contains("x-foo'"))); + + let q = split_for_cascade("name:%_\\").unwrap(); + // LIKE-escaped inside the bound param, not the SQL. + assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%\\_\\\\%")); + } + + #[test] + fn unquoted_star_builds_a_wildcard_pattern() { + let q = split_for_cascade("foo*").unwrap(); + assert_eq!(q.term, "foo*"); + assert!(q.pattern.is_wildcard()); + assert!(q.pattern.whole_match("foobar", false)); + } + + #[test] + fn quoted_star_stays_literal() { + let q = split_for_cascade("\"foo*\"").unwrap(); + assert_eq!(q.pattern.literal(), Some("foo*")); + } + + #[test] + fn bare_star_matches_nothing() { + for input in ["*", "**", "* *"] { + let q = split_for_cascade(input).unwrap(); + assert!( + q.pattern.find_first("anything", true).is_none(), + "{:?}", + input + ); + } + // "* *" has an interior literal space; plain stars are Empty. + assert!(split_for_cascade("*").unwrap().pattern.is_empty()); + assert!(split_for_cascade("*").unwrap().is_empty()); + } + + #[test] + fn glued_unknown_keys_keep_stars_literal() { + let q = split_for_cascade("foo:ba*r").unwrap(); + assert_eq!(q.pattern.literal(), Some("foo:ba*r")); + } + + #[test] + fn lex_error_degrade_keeps_stars_literal() { + let q = split_for_cascade("re*port \"unclosed").unwrap(); + assert_eq!(q.pattern.literal(), Some("re*port \"unclosed")); + } + + #[test] + fn regex_keyword_compiles_out_of_band() { + let q = split_for_cascade("regex:foo\\d+").unwrap(); + assert_eq!(q.term, ""); + assert!(q.pattern.is_empty()); + assert!(!q.is_empty(), "a regex-only query still searches"); + let re = q.regex.unwrap(); + assert!(re.is_match("FOO12")); + assert!(q.filter_sql.is_empty(), "regex is not a SQL filter"); + } + + #[test] + fn regex_value_may_be_quoted_and_key_is_case_insensitive() { + let q = split_for_cascade("REGEX:\"foo (bar|baz)\"").unwrap(); + assert!(q.regex.unwrap().is_match("foo bar")); + } + + #[test] + fn regex_mixes_with_filters_and_term() { + let q = split_for_cascade("regex:\\d+ type:Text budget").unwrap(); + assert_eq!(q.term, "budget"); + assert!(q.regex.is_some()); + assert_eq!(q.filter_sql.matches(" AND (").count(), 1); + } + + #[test] + fn regex_error_shapes() { + assert!(matches!( + split_for_cascade("regex:["), + Err(TranslateError::BadRegex(_)) + )); + assert!(matches!( + split_for_cascade("regex=x"), + Err(TranslateError::UnsupportedOp { .. }) + )); + assert!(matches!( + split_for_cascade("regex:a regex:b"), + Err(TranslateError::BadRegex(_)) + )); + // Empty-matchable patterns are rejected loudly. + assert!(matches!( + split_for_cascade("regex:.*"), + Err(TranslateError::BadRegex(_)) + )); + } + + #[test] + fn dangling_regex_key_is_literal_text() { + let q = split_for_cascade("regex:").unwrap(); + assert_eq!(q.term, "regex:"); + assert!(q.regex.is_none()); + } + + #[test] + fn name_filter_star_becomes_like_wildcard() { + let q = split_for_cascade("name:foo*bar").unwrap(); + assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%foo%bar%")); + + // Quoted value: star stays a literal character. + let q = split_for_cascade("name:\"fo*o\"").unwrap(); + assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%fo*o%")); + + // User LIKE metacharacters stay escaped even in glob values. + let q = split_for_cascade("name:%*_").unwrap(); + assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "%\\%%\\_%")); + + // path: values never glob. + let q = split_for_cascade("path:/da*ta").unwrap(); + assert!(matches!(&q.filter_params[0], Value::Text(t) if t == "/da*ta")); + } + + #[test] + fn nul_bytes_stripped_and_long_terms_pass_through() { + // NULs would break SQLite binding / FTS5 parsing downstream. + let q = split_for_cascade("abc\0def").unwrap(); + assert_eq!(q.term, "abcdef"); + + let long = "x".repeat(10_240); + let q = split_for_cascade(&long).unwrap(); + assert_eq!(q.term.len(), 10_240); + } +} diff --git a/crates/quicksearch-core/src/query/translator.rs b/crates/quicksearch-core/src/query/translator.rs index 8eb58a9..118b89c 100644 --- a/crates/quicksearch-core/src/query/translator.rs +++ b/crates/quicksearch-core/src/query/translator.rs @@ -35,6 +35,7 @@ pub enum TranslateError { Parse(ParseError), UnknownProperty(String), BadDate(String), + BadRegex(String), UnsupportedOp { key: String, op: Op, @@ -47,6 +48,7 @@ impl std::fmt::Display for TranslateError { TranslateError::Parse(e) => write!(f, "{}", e), TranslateError::UnknownProperty(k) => write!(f, "unknown property '{}'", k), 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) } @@ -172,8 +174,10 @@ fn fts_expr(parts: &[FtsFragment]) -> String { } /// Escape a phrase for FTS5 MATCH. FTS5 itself uses doubled quotes for -/// literal quotes inside a quoted phrase. -fn quote_phrase(s: &str) -> String { +/// literal quotes inside a quoted phrase; wrapping in quotes renders all +/// other MATCH metacharacters (`( ) * :` etc.) inert. Injection-safe by +/// construction. +pub fn quote_phrase(s: &str) -> String { let mut buf = String::with_capacity(s.len() + 2); buf.push('"'); for c in s.chars() { @@ -243,119 +247,214 @@ impl Builder { } } + /// Delegate to the shared [`build_filter`] fragment builder, then + /// convert its anonymous `?` placeholders to this builder's numbered + /// scheme (params[0] is reserved for the FTS MATCH when one exists; + /// `build` shifts numbers afterwards). fn translate_property( &mut self, key: &str, op: Op, value: &str, ) -> Result { - let lower_key = key.to_ascii_lowercase(); - match lower_key.as_str() { - "type" => self.prop_type(op, value, key), - "modified" | "mtime" => self.prop_mtime(op, value, key), - "path" | "folder" | "includefolder" => self.prop_path(op, value, key), - "name" | "filename" => self.prop_name(op, value, key), - "mime" => self.prop_mime(op, value, key), - _ => Err(TranslateError::UnknownProperty(key.to_string())), + let frag = build_filter(key, op, value, false)?; + let mut params = frag.params.into_iter(); + let mut out = String::with_capacity(frag.sql.len() + 8); + for c in frag.sql.chars() { + if c == '?' { + let v = params + .next() + .expect("FilterFragment placeholder/param counts match"); + self.all_params.push(v); + out.push('?'); + out.push_str(&self.all_params.len().to_string()); + } else { + out.push(c); + } } + Ok(out) } +} - fn prop_type(&mut self, op: Op, value: &str, key: &str) -> Result { +/// A structured-filter fragment over table alias `f`: SQL with anonymous +/// `?` placeholders plus the values they bind. Anonymous placeholders +/// compose by simple appending — the search cascade tacks fragments onto +/// every stage's WHERE clause with `AND (...)`. +#[derive(Debug, Clone)] +pub struct FilterFragment { + pub sql: String, + pub params: Vec, +} + +/// Whether `key` is a recognized structured-filter property. +pub fn is_filter_key(key: &str) -> bool { + matches!( + key.to_ascii_lowercase().as_str(), + "type" | "modified" | "mtime" | "path" | "folder" | "includefolder" | "name" + | "filename" | "mime" + ) +} + +/// Translate one `key op value` filter into a [`FilterFragment`]. The +/// single source of filter semantics, shared by the legacy numbered +/// [`build`] path and the cascade's [`super::split`]. +/// +/// `glob` marks a value whose unquoted `*` should act as a wildcard — only +/// `name:`/`filename:` honor it; every other key treats the star literally. +/// The caller decides, because only the tokenizer knows whether the value +/// was quoted (quoted stars are always literal). +pub fn build_filter( + key: &str, + op: Op, + value: &str, + glob: bool, +) -> Result { + use rusqlite::types::Value; + let frag = |sql: &str, params: Vec| FilterFragment { + sql: sql.to_string(), + params, + }; + let eq_like_only = |op: Op| -> Result<(), TranslateError> { if op != Op::Contains && op != Op::Eq { return Err(TranslateError::UnsupportedOp { key: key.into(), op, }); } - let bits = FileType::from_name(value).bits() as i64; - if bits == 0 { - return Err(TranslateError::UnknownProperty(format!( - "type name '{}'", + Ok(()) + }; + + match key.to_ascii_lowercase().as_str() { + "type" => { + eq_like_only(op)?; + let bits = FileType::from_name(value).bits() as i64; + if bits == 0 { + return Err(TranslateError::UnknownProperty(format!( + "type name '{}'", + value + ))); + } + Ok(frag("(f.type & ?) != 0", vec![Value::Integer(bits)])) + } + "modified" | "mtime" => { + let unix = + parse_date_to_unix(value).ok_or_else(|| TranslateError::BadDate(value.into()))?; + // `modified:=2024-01-01` matches the whole day, not the second. + if op == Op::Eq || op == Op::Contains { + return Ok(frag( + "(f.mtime >= ? AND f.mtime < ?)", + vec![Value::Integer(unix), Value::Integer(unix + 86_400)], + )); + } + let sql_op = match op { + Op::Lt => "<", + Op::Le => "<=", + Op::Gt => ">", + Op::Ge => ">=", + Op::Contains | Op::Eq => unreachable!(), + }; + Ok(frag( + &format!("f.mtime {} ?", sql_op), + vec![Value::Integer(unix)], + )) + } + "path" | "folder" | "includefolder" => { + eq_like_only(op)?; + let base = normalize_folder_value(value); + if base.is_empty() { + // "everything". On Unix the old `parent = '/' OR parent LIKE + // '/%'` happened to match every absolute path; Windows has no + // single root, so say it directly rather than by accident. + return Ok(frag("1=1", Vec::new())); + } + // The `=` half needs the collation spelled out: `LIKE` folds ASCII + // case on its own, so without this the two halves of the same + // filter disagree about `C:\Users` versus `c:\users`. + Ok(frag( + &format!( + "(f.parent = ? COLLATE {} OR f.parent LIKE ? ESCAPE '\\')", + crate::platform::PATH_COLLATION + ), + vec![ + Value::Text(base.clone()), + Value::Text(like_subtree_pattern(&base)), + ], + )) + } + "name" | "filename" => { + if op != Op::Contains { + return Err(TranslateError::UnsupportedOp { + key: key.into(), + op, + }); + } + // With `glob`, each `*` becomes an unescaped `%`; the pieces + // around it still get `%`/`_`/`\` escaped so user metacharacters + // stay literal either way. + let pattern = if glob && value.contains('*') { value - ))); + .split('*') + .map(escape_like) + .collect::>() + .join("%") + } else { + escape_like(value) + }; + Ok(frag( + "f.name LIKE ? ESCAPE '\\'", + vec![Value::Text(format!("%{}%", pattern))], + )) } - self.all_params - .push(rusqlite::types::Value::Integer(bits)); - Ok(format!("(f.type & ?{}) != 0", self.param_placeholder_idx())) - } - - fn prop_mtime(&mut self, op: Op, value: &str, key: &str) -> Result { - let unix = parse_date_to_unix(value).ok_or_else(|| TranslateError::BadDate(value.into()))?; - let col = "f.mtime"; - let sql_op = match op { - Op::Contains | Op::Eq => "=", - Op::Lt => "<", - Op::Le => "<=", - Op::Gt => ">", - Op::Ge => ">=", - }; - // `modified:=2024-01-01` should match the whole day, not the second. - if op == Op::Eq || op == Op::Contains { - let start = unix; - let end = unix + 86_400; - self.all_params.push(rusqlite::types::Value::Integer(start)); - let i = self.param_placeholder_idx(); - self.all_params.push(rusqlite::types::Value::Integer(end)); - let j = self.param_placeholder_idx(); - return Ok(format!("({} >= ?{} AND {} < ?{})", col, i, col, j)); + "mime" => { + eq_like_only(op)?; + Ok(frag("f.mime = ?", vec![Value::Text(value.into())])) } - self.all_params.push(rusqlite::types::Value::Integer(unix)); - let i = self.param_placeholder_idx(); - let _ = key; - Ok(format!("{} {} ?{}", col, sql_op, i)) + _ => Err(TranslateError::UnknownProperty(key.to_string())), } +} - fn prop_path(&mut self, op: Op, value: &str, key: &str) -> Result { - if op != Op::Contains && op != Op::Eq { - return Err(TranslateError::UnsupportedOp { - key: key.into(), - op, - }); +/// Escape `%`, `_` and `\` for use inside a `LIKE ... ESCAPE '\'` pattern. +pub fn escape_like(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '%' | '_' | '\\') { + out.push('\\'); } - self.all_params - .push(rusqlite::types::Value::Text(value.into())); - let i = self.param_placeholder_idx(); - self.all_params - .push(rusqlite::types::Value::Text(format!("{}/%", value.trim_end_matches('/')))); - let j = self.param_placeholder_idx(); - Ok(format!("(f.parent = ?{} OR f.parent LIKE ?{})", i, j)) + out.push(c); } + out +} - fn prop_name(&mut self, op: Op, value: &str, key: &str) -> Result { - if op != Op::Contains { - return Err(TranslateError::UnsupportedOp { - key: key.into(), - op, - }); - } - self.all_params - .push(rusqlite::types::Value::Text(format!("%{}%", value))); - let i = self.param_placeholder_idx(); - Ok(format!("f.name LIKE ?{}", i)) +/// Tidy a user-supplied folder value into the spelling `files.parent` stores. +/// +/// Trailing separators are how people naturally write directories, and either +/// separator may show up on Windows. A bare drive (`C:`) is *not* a path — the +/// stored parent is `C:\` — so the separator goes back on. +fn normalize_folder_value(value: &str) -> String { + let base = value.trim().trim_end_matches(['/', '\\']); + if base.len() == 2 && base.ends_with(':') && base.starts_with(|c: char| c.is_ascii_alphabetic()) + { + return format!("{}{}", base, std::path::MAIN_SEPARATOR); } + base.to_string() +} - fn prop_mime(&mut self, op: Op, value: &str, key: &str) -> Result { - if op != Op::Contains && op != Op::Eq { - return Err(TranslateError::UnsupportedOp { - key: key.into(), - op, - }); - } - self.all_params - .push(rusqlite::types::Value::Text(value.into())); - let i = self.param_placeholder_idx(); - Ok(format!("f.mime = ?{}", i)) - } - - fn param_placeholder_idx(&self) -> usize { - // params[0] is reserved for the FTS MATCH if one is built; structured - // params start at index 2 in that case (1-based). We track it by - // calling this *after* pushing the value; result is `len` so the SQL - // says `?` which matches the 1-based positional binding rusqlite - // uses for `?N` placeholders. When an FTS match is prepended at - // `build`, each index shifts by 1 implicitly. - self.all_params.len() - } +/// A `LIKE ... ESCAPE '\'` pattern matching every path strictly beneath `dir`. +/// +/// The separator is escaped along with the base, because on Windows the +/// separator *is* the escape character — a hand-written `format!("{}/%", dir)` +/// is wrong twice over there: wrong separator, and the one it emits would be +/// swallowed as an escape. +/// +/// SQLite's `patternCompare` takes the character after the escape literally +/// whatever it is, so a doubled `\` is well defined here; the folklore that an +/// escape must be followed by `%`, `_` or itself does not apply. +pub fn like_subtree_pattern(dir: &str) -> String { + format!( + "{}{}%", + escape_like(dir.trim_end_matches(['/', '\\'])), + escape_like(std::path::MAIN_SEPARATOR_STR) + ) } fn join_with(sep: &str, pieces: &[String]) -> String { @@ -497,6 +596,101 @@ mod tests { let q = build_q("path:/home/me/docs"); assert!(q.sql.contains("f.parent = ?")); assert!(q.sql.contains("f.parent LIKE ?")); + // The LIKE half must be escaped and declare its escape character; + // without the clause a Windows separator would be eaten as an escape. + assert!(q.sql.contains("ESCAPE '\\'"), "{}", q.sql); + } + + /// The subtree pattern is the one place the separator and the LIKE escape + /// character collide (on Windows they are the same byte), so it is checked + /// against real SQLite rather than by string comparison. + #[test] + fn like_subtree_pattern_matches_only_the_subtree() { + use std::path::MAIN_SEPARATOR as SEP; + + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute("CREATE TABLE files (parent TEXT NOT NULL)", []) + .unwrap(); + + let base = format!("{}a{}b", root_prefix(), SEP); + let rows = [ + 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 + ]; + for r in &rows { + conn.execute("INSERT INTO files (parent) VALUES (?1)", [r]) + .unwrap(); + } + + let matched: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files WHERE parent LIKE ?1 ESCAPE '\\'", + [like_subtree_pattern(&base)], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(matched, 2, "only the two rows strictly beneath {}", base); + } + + #[test] + fn like_subtree_pattern_escapes_metacharacters() { + use std::path::MAIN_SEPARATOR as SEP; + + let conn = rusqlite::Connection::open_in_memory().unwrap(); + conn.execute("CREATE TABLE files (parent TEXT NOT NULL)", []) + .unwrap(); + + let base = format!("{}a_b", root_prefix()); + for r in [ + format!("{}{}inside", base, SEP), // real child + format!("{}axb{}bait", root_prefix(), SEP), // `_` must not glob to `x` + format!("{}100%_done{}x", root_prefix(), SEP), + ] { + conn.execute("INSERT INTO files (parent) VALUES (?1)", [&r]) + .unwrap(); + } + + let matched: i64 = conn + .query_row( + "SELECT COUNT(*) FROM files WHERE parent LIKE ?1 ESCAPE '\\'", + [like_subtree_pattern(&base)], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(matched, 1, "`_` and `%` are literals, not wildcards"); + } + + #[test] + fn folder_value_normalization() { + use std::path::MAIN_SEPARATOR as SEP; + // Trailing separators of either flavour are stripped. + assert_eq!(normalize_folder_value("/home/me/"), "/home/me"); + assert_eq!(normalize_folder_value(r"C:\Users\me\"), r"C:\Users\me"); + // A bare drive is not a path; the stored parent is `C:\`. + assert_eq!(normalize_folder_value("C:"), format!("C:{}", SEP)); + assert_eq!(normalize_folder_value(r"C:\"), format!("C:{}", SEP)); + // Empty means "everywhere". + assert_eq!(normalize_folder_value("/"), ""); + assert_eq!(normalize_folder_value(" "), ""); + } + + #[test] + fn empty_folder_value_matches_everything() { + let frag = build_filter("path", Op::Contains, "/", false).unwrap(); + assert_eq!(frag.sql, "1=1"); + assert!(frag.params.is_empty(), "no placeholders to renumber"); + } + + /// An absolute-path prefix for the running platform. + fn root_prefix() -> String { + if cfg!(windows) { + r"C:\".to_string() + } else { + "/".to_string() + } } #[test] diff --git a/crates/quicksearch-core/src/search/cascade.rs b/crates/quicksearch-core/src/search/cascade.rs new file mode 100644 index 0000000..0d020ee --- /dev/null +++ b/crates/quicksearch-core/src/search/cascade.rs @@ -0,0 +1,937 @@ +//! The ranked search cascade. +//! +//! One term, four table scans, eleven ranks. Rank base = stage number, so +//! later stages only ever append to a rank-sorted result list: +//! +//! | rank | meaning | scan | +//! |-----:|----------------------------------|------| +//! | 1.x | exact filename, exact case | A | +//! | 2.x | exact filename, any case | A | +//! | 3.x | filename substring, exact case | A | +//! | 4.x | filename substring, any case | A | +//! | 5.x | full text occurrence, exact case | B | +//! | 6.x | full text occurrence, any case | B | +//! | 7.x | fuzzy filename | C | +//! | 8.x | fuzzy full text | D | +//! | 9.x | full path substring, exact case | A | +//! | 10.x | full path substring, any case | A | +//! | 11.x | fuzzy full path | C | +//! +//! Pass A is a single `files` scan (`LIKE`, the ASCII-nocase superset of +//! its ranks) classified per-row in Rust — no index needed, the substring +//! stage visits every row anyway. Because a path always ends in its own +//! name, `path LIKE` is a superset of `name LIKE`, so that one scan covers +//! the filename *and* the path tiers. Pass B is one FTS phrase MATCH +//! verified against the decompressed text. Passes C/D (opt-in) iterate the +//! whole table with a bitap matcher, C covering both the name and the path. +//! +//! Wildcard terms (`rep*rt`) rank through the same tiers, with 1/2 meaning +//! the whole name matches the pattern; they skip the fuzzy passes (bitap is +//! a literal matcher). A regex-only query (`regex:…` with no term) runs two +//! dedicated scans that reuse tiers 4 (name), 6 (content) and 10 (path), so +//! downstream stage handling is unchanged. When `regex:` accompanies a +//! term, it is an accept-predicate on every pass, not a rank source. +//! +//! The path tiers rank below everything else, so pass A and pass C buffer +//! them instead of emitting them — stages E and F flush those buffers at +//! the end, dropping files an earlier stage already emitted. Path matching +//! needs a term of at least three characters, the same floor pass B has. +//! +//! Full-text ranks order equal-based hits by occurrence count via a +//! decimal fraction: `base + (1000 - min(count, 1000)) / 1000` — more +//! occurrences sorts earlier, 1000+ occurrences adds zero. Fuzzy ranks add +//! `0.1 × edit_distance` instead. +//! +//! Every scan appends the caller's structured-filter SQL (anonymous +//! placeholders over alias `f`) and checks the generation counter as it +//! streams; a bumped generation aborts mid-statement. + +use std::collections::HashSet; +use std::sync::atomic::{AtomicU64, Ordering}; + +use rusqlite::Connection; + +use rusqlite::OptionalExtension; + +use crate::config::IgnoreSet; +use crate::query::pattern::clamp_match_range; +use crate::query::split::CascadeQuery; +use crate::query::translator::{escape_like, quote_phrase}; +use crate::snippet; + +use super::fuzzy::{edit_budget, Bitap}; +use super::{SearchHit, SearchOptions}; + +/// Cancellation is checked every this many scanned rows in row-cheap +/// passes; decompression-heavy passes check every row. +const CANCEL_CHECK_ROWS: usize = 256; + +/// Snippet window budget. Generous on purpose: the GUI trims the cell +/// text down to its column width around the match, and the mouseover +/// shows the rest of this window as extended context. +const SNIPPET_WINDOW_CHARS: usize = 600; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Outcome { + pub total: usize, + pub limited: bool, +} + +/// Run the cascade, streaming rank-ordered batches into `sink`. +/// `Ok(None)` means the search was cancelled (generation moved on) — the +/// caller sends no completion. SQL errors are returned as strings *unless* +/// the search was already cancelled (an interrupted statement is normal +/// cancellation, not an error). +pub fn run( + conn: &Connection, + query: &CascadeQuery, + options: &SearchOptions, + generation: u64, + latest_gen: &AtomicU64, + sink: &mut dyn FnMut(Vec), +) -> Result, String> { + if query.is_empty() { + return Ok(Some(Outcome { + total: 0, + limited: false, + })); + } + let ignore = IgnoreSet::compile(&options.session_ignores) + .map_err(|e| format!("session ignore filter: {}", e))?; + + let mut cx = Cx { + conn, + query, + options, + generation, + latest_gen, + ignore, + emitted: HashSet::new(), + deferred_path: Deferred::default(), + deferred_fuzzy_path: Deferred::default(), + total: 0, + limited: false, + sink, + }; + + // With no term at all the regex drives its own scans; `Path` still + // flushes the deferred rank-10 buffer the name pass sets aside. + let passes: &[Pass] = if query.pattern.is_empty() { + &[Pass::RegexName, Pass::RegexContent, Pass::Path] + } else { + &[ + Pass::Filename, + Pass::FullText, + Pass::FuzzyFilename, + Pass::FuzzyFullText, + Pass::Path, + Pass::FuzzyPath, + ] + }; + for pass in passes { + if cx.cancelled() { + return Ok(None); + } + if cx.remaining() == 0 { + cx.limited = true; + break; + } + let run_pass = match pass { + Pass::Filename => cx.pass_filename(), + Pass::FullText => cx.pass_fulltext(), + Pass::FuzzyFilename => cx.pass_fuzzy_filename(), + Pass::FuzzyFullText => cx.pass_fuzzy_fulltext(), + Pass::RegexName => cx.pass_regex_name(), + Pass::RegexContent => cx.pass_regex_content(), + Pass::Path => { + let d = std::mem::take(&mut cx.deferred_path); + cx.flush_deferred(d) + } + Pass::FuzzyPath => { + let d = std::mem::take(&mut cx.deferred_fuzzy_path); + cx.flush_deferred(d) + } + }; + match run_pass { + Ok(true) => {} + Ok(false) => return Ok(None), // cancelled mid-pass + Err(e) => { + if cx.cancelled() { + return Ok(None); // interrupt() killed the statement + } + return Err(e); + } + } + } + + Ok(Some(Outcome { + total: cx.total, + limited: cx.limited, + })) +} + +enum Pass { + Filename, + FullText, + FuzzyFilename, + FuzzyFullText, + /// Regex-only: name hits at rank 4 now, path hits deferred to rank 10. + RegexName, + /// Regex-only: content hits at rank 6. + RegexContent, + /// Flush of the rank 9–10 hits pass A set aside. + Path, + /// Flush of the rank 11 hits pass C set aside. + FuzzyPath, +} + +/// Occurrence-count fraction: more occurrences → smaller fraction → sorts +/// earlier within a rank base; 1000+ adds zero. +fn count_frac(count: usize) -> f64 { + (1000usize.saturating_sub(count.min(1000))) as f64 / 1000.0 +} + +/// The path tiers only make sense with enough term to be specific — same +/// floor the trigram full-text pass uses. Wildcards count only their +/// literal content (`a*b` is two characters of specificity, not three). +fn path_tiers_enabled(pattern: &crate::query::pattern::TermPattern) -> bool { + pattern.literal_char_count() >= 3 +} + +/// Hits collected by one scan but ranked below later scans, so held back +/// until every better stage has emitted. +#[derive(Default)] +struct Deferred { + hits: Vec, + overflowed: bool, +} + +struct Cx<'a> { + conn: &'a Connection, + query: &'a CascadeQuery, + options: &'a SearchOptions, + generation: u64, + latest_gen: &'a AtomicU64, + ignore: IgnoreSet, + emitted: HashSet, + /// Ranks 9–10, filled by pass A. + deferred_path: Deferred, + /// Rank 11, filled by pass C. + deferred_fuzzy_path: Deferred, + total: usize, + limited: bool, + sink: &'a mut dyn FnMut(Vec), +} + +impl<'a> Cx<'a> { + fn cancelled(&self) -> bool { + self.generation != self.latest_gen.load(Ordering::Relaxed) + } + + fn remaining(&self) -> usize { + self.options.limit.saturating_sub(self.total) + } + + /// Buffer cap for scan passes: enough headroom that sorting keeps the + /// best candidates, without unbounded growth on huge hit sets. + fn buffer_cap(&self) -> usize { + 4096.max(2 * self.remaining()) + } + + fn params_with_filters( + &self, + leading: Vec, + ) -> Vec { + let mut p = leading; + p.extend(self.query.filter_params.iter().cloned()); + p + } + + /// Skip rows already emitted at a better rank or hidden by session + /// ignore chips. + fn skip(&self, file_id: i64, path: &str) -> bool { + self.emitted.contains(&file_id) || self.ignore.matches_path(std::path::Path::new(path)) + } + + /// The `regex:` accept-predicate applied to every candidate row when a + /// regex accompanies a term. The path contains the name, so one path + /// 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 { + let Some(re) = &self.query.regex else { + return Ok(true); + }; + if re.is_match(path) { + return Ok(true); + } + if let Some(text) = text { + return Ok(re.is_match(text)); + } + let blob: Option> = self + .conn + .query_row( + "SELECT text_zstd FROM documents_text WHERE file_id = ?1", + [file_id], + |r| r.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let Some(raw) = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()) else { + return Ok(false); + }; + Ok(re.is_match(&String::from_utf8_lossy(&raw))) + } + + /// Sort a finished pass buffer, truncate to what's left of the display + /// limit, and stream it out in `options.batch`-sized events. + fn flush_pass(&mut self, mut buf: Vec, overflowed: bool) { + buf.sort_by(|a, b| { + a.rank + .partial_cmp(&b.rank) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + let room = self.remaining(); + if buf.len() > room { + buf.truncate(room); + self.limited = true; + } + if overflowed { + self.limited = true; + } + self.total += buf.len(); + for hit in &buf { + self.emitted.insert(hit.file_id); + } + let batch = self.options.batch.max(1); + let mut buf = buf.into_iter().peekable(); + while buf.peek().is_some() { + // A cancelled search stops emitting immediately — the newer + // generation owns the UI. + if self.cancelled() { + return; + } + let chunk: Vec = buf.by_ref().take(batch).collect(); + (self.sink)(chunk); + } + } + + /// Emit a buffer held back from an earlier scan. Anything a better + /// stage already emitted drops out here — `emitted` was still empty (or + /// smaller) when these hits were collected. + fn flush_deferred(&mut self, mut deferred: Deferred) -> Result { + deferred.hits.retain(|h| !self.emitted.contains(&h.file_id)); + self.flush_pass(deferred.hits, deferred.overflowed); + Ok(true) + } + + /// Keep a scan buffer bounded: sort + cut back to the display-limit + /// room once it doubles past it. Returns whether anything was dropped. + fn enforce_cap(&self, buf: &mut Vec) -> bool { + if buf.len() <= self.buffer_cap() { + return false; + } + buf.sort_by(|a, b| { + a.rank + .partial_cmp(&b.rank) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + buf.truncate(self.remaining()); + true + } + + /// Pass A — ranks 1–4 now, ranks 9–10 deferred, from one `files` scan. + /// Returns Ok(false) on cancellation. + fn pass_filename(&mut self) -> Result { + let query = self.query; + let pattern = &query.pattern; + let with_paths = path_tiers_enabled(pattern); + // A path always ends in its own name, so `path LIKE` is the + // superset that feeds both the name and the path tiers. + let sql = format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime FROM files f \ + WHERE {} LIKE ? ESCAPE '\\'{}", + if with_paths { "f.path" } else { "f.name" }, + query.filter_sql + ); + // Wildcard patterns turn each star into an unescaped `%`; the + // substring wrap absorbs leading/trailing stars. User `%`/`_` + // remain escaped literals either way. + let like = pattern + .segments() + .iter() + .map(|s| escape_like(s)) + .collect::>() + .join("%"); + 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 + .query(rusqlite::params_from_iter(params)) + .map_err(|e| e.to_string())?; + + let mut buf: Vec = Vec::new(); + let mut path_buf: Vec = Vec::new(); + let mut overflowed = false; + let mut path_overflowed = false; + let mut scanned = 0usize; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + scanned += 1; + if scanned % CANCEL_CHECK_ROWS == 0 && self.cancelled() { + return Ok(false); + } + let file_id: i64 = row.get(0).map_err(|e| e.to_string())?; + let name: String = row.get(1).map_err(|e| e.to_string())?; + let path: String = row.get(2).map_err(|e| e.to_string())?; + if self.skip(file_id, &path) { + continue; + } + // For a literal pattern these are exactly the old `==` / + // `eq_ignore_ascii_case` / `find` / folded-`find` operations + // (folding is byte-length preserving, so folded offsets are + // valid in the original). Wildcards run the same ladder through + // their compiled matcher — tiers 1/2 mean "whole name matches + // the pattern", which is what `*.txt` should do. + let (rank, match_range) = if pattern.whole_match(&name, false) { + (1.0, (0, name.len())) + } else if pattern.whole_match(&name, true) { + (2.0, (0, name.len())) + } else if let Some(r) = pattern.find_first(&name, false) { + (3.0, (r.start, r.end)) + } else if let Some(r) = pattern.find_first(&name, true) { + (4.0, (r.start, r.end)) + } else if !with_paths { + continue; + } else if let Some(r) = pattern.find_first(&path, false) { + (9.0, (r.start, r.end)) + } else if let Some(r) = pattern.find_first(&path, true) { + (10.0, (r.start, r.end)) + } else { + // LIKE folds ASCII case only; a row that matched it but + // neither field is a non-ASCII near-miss. Drop it. + continue; + }; + if !self.regex_accepts(file_id, &path, None)? { + continue; + } + let is_path_tier = rank >= 9.0; + // The "snippet" of a name or path hit is that field itself with + // 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() }, + truncated_start: false, + truncated_end: false, + }; + let hit = SearchHit { + file_id, + name, + path, + size: row.get::<_, i64>(3).map_err(|e| e.to_string())?.max(0) as u64, + mtime: row.get(4).map_err(|e| e.to_string())?, + rank, + stage: rank as u8, + snippet: Some(snip), + }; + if is_path_tier { + path_buf.push(hit); + path_overflowed |= self.enforce_cap(&mut path_buf); + } else { + buf.push(hit); + overflowed |= self.enforce_cap(&mut buf); + } + } + drop(rows); + if self.cancelled() { + return Ok(false); + } + self.deferred_path = Deferred { + hits: path_buf, + overflowed: path_overflowed, + }; + self.flush_pass(buf, overflowed); + Ok(true) + } + + /// Pass B — ranks 5–6 from one FTS MATCH, verified/counted in the + /// decompressed text. + fn pass_fulltext(&mut self) -> Result { + let query = self.query; + let pattern = &query.pattern; + if pattern.literal_char_count() < 3 { + // Below the trigram floor the MATCH can't return anything. + return Ok(true); + } + // Column filter goes inside the MATCH expression (`text: "..."`) + // so only document bodies match — filenames get ranks 1-4 from the + // filename pass instead. A literal term is one quoted phrase; a + // wildcard term narrows with an AND of its trigram-sized segments. + // With no segment of 3+ chars (`ab*cd`) FTS can't narrow at all, so + // fall back to scanning every stored document — every row is + // pattern-verified either way. + let match_expr: Option = match pattern.literal() { + Some(term) => Some(format!("text: {}", quote_phrase(term))), + None => { + let usable: Vec = pattern + .segments() + .iter() + .filter(|s| s.chars().count() >= 3) + .map(|s| format!("text: {}", quote_phrase(s))) + .collect(); + if usable.is_empty() { + None + } else { + Some(usable.join(" AND ")) + } + } + }; + let narrowed = match_expr.is_some(); + let (sql, params) = match match_expr { + Some(expr) => ( + format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime, dt.text_zstd \ + FROM searchabletext \ + JOIN files f ON f.id = searchabletext.rowid \ + LEFT JOIN documents_text dt ON dt.file_id = f.id \ + WHERE searchabletext MATCH ?{}", + query.filter_sql + ), + self.params_with_filters(vec![rusqlite::types::Value::Text(expr)]), + ), + None => ( + format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime, dt.text_zstd \ + FROM documents_text dt \ + JOIN files f ON f.id = dt.file_id WHERE 1=1{}", + query.filter_sql + ), + self.params_with_filters(Vec::new()), + ), + }; + + let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; + let mut rows = stmt + .query(rusqlite::params_from_iter(params)) + .map_err(|e| e.to_string())?; + + let snippet_opts = snippet::Options { approx_chars: SNIPPET_WINDOW_CHARS }; + let mut buf: Vec = Vec::new(); + let mut overflowed = false; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + // Decompression dominates: check every row. + if self.cancelled() { + return Ok(false); + } + let file_id: i64 = row.get(0).map_err(|e| e.to_string())?; + let path: String = row.get(2).map_err(|e| e.to_string())?; + if self.skip(file_id, &path) { + 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 (rank, stage, snip) = match &text { + Some(text) => { + let (count, stage, ci) = { + let count_cs = pattern.count(text, false); + if count_cs > 0 { + (count_cs, 5, false) + } else { + let count_ci = pattern.count(text, true); + if count_ci > 0 { + (count_ci, 6, true) + } else { + // Folded/unordered FTS candidate: the + // pattern never occurs — drop it. + continue; + } + } + }; + // Literal terms keep the richer multi-occurrence + // extract; a wildcard match marks its own first range. + let snip = match pattern.literal() { + Some(term) => Some(snippet::extract(text, &[term], &snippet_opts)), + None => pattern.find_first(text, ci).map(|r| { + let r = clamp_match_range(text, r, SNIPPET_WINDOW_CHARS); + snippet::window_around(text, (r.start, r.end), &snippet_opts) + }), + }; + (stage as f64 + count_frac(count), stage as u8, snip) + } + // No stored text (store_text_for_snippets = false or empty + // body): can't case-verify or count. On the FTS-narrowed + // path accept at the bottom of rank 6 as count-unknown (for + // wildcards the AND-of-segments guarantee is weaker — + // unordered co-occurrence — accepted for recall). On the + // full-scan fallback there is no FTS evidence at all, so an + // unverifiable row is just skipped. + None => { + if !narrowed { + continue; + } + (6.0 + count_frac(1), 6, None) + } + }; + if !self.regex_accepts(file_id, &path, text.as_deref())? { + continue; + } + + buf.push(SearchHit { + file_id, + name: row.get(1).map_err(|e| e.to_string())?, + path, + size: row.get::<_, i64>(3).map_err(|e| e.to_string())?.max(0) as u64, + mtime: row.get(4).map_err(|e| e.to_string())?, + rank, + stage, + snippet: snip, + }); + overflowed |= self.enforce_cap(&mut buf); + } + drop(rows); + if self.cancelled() { + return Ok(false); + } + self.flush_pass(buf, overflowed); + Ok(true) + } + + /// Pass C — rank 7 now, rank 11 deferred: one bitap sweep over every + /// filename, falling back to the full path where the name misses. + fn pass_fuzzy_filename(&mut self) -> Result { + if !self.options.fuzzy { + return Ok(true); + } + // Bitap is a literal matcher; wildcard terms don't fuzz. + if self.query.pattern.is_wildcard() { + return Ok(true); + } + let folded_term = self.query.term.to_ascii_lowercase(); + let Some(k) = edit_budget(folded_term.len(), self.options.fuzzy_max_edits) else { + return Ok(true); + }; + let Some(bitap) = Bitap::new(folded_term.as_bytes(), k) else { + return Ok(true); + }; + let with_paths = path_tiers_enabled(&self.query.pattern); + + let sql = format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime FROM files f WHERE 1=1{}", + self.query.filter_sql + ); + let params = self.params_with_filters(Vec::new()); + let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; + let mut rows = stmt + .query(rusqlite::params_from_iter(params)) + .map_err(|e| e.to_string())?; + + let mut buf: Vec = Vec::new(); + let mut path_buf: Vec = Vec::new(); + let mut overflowed = false; + let mut path_overflowed = false; + let mut scanned = 0usize; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + scanned += 1; + if scanned % 1024 == 0 && self.cancelled() { + return Ok(false); + } + let file_id: i64 = row.get(0).map_err(|e| e.to_string())?; + let name: String = row.get(1).map_err(|e| e.to_string())?; + let path: String = row.get(2).map_err(|e| e.to_string())?; + if self.skip(file_id, &path) { + continue; + } + // The name is the better match when both fire, so it wins and + // only a name miss falls through to the path tier. + let folded_name = name.to_ascii_lowercase(); + let (rank, field, folded_field) = match bitap.best_distance(folded_name.as_bytes()) { + Some(distance) => (7.0 + 0.1 * distance as f64, &name, folded_name), + None if with_paths => { + let folded_path = path.to_ascii_lowercase(); + match bitap.best_distance(folded_path.as_bytes()) { + Some(distance) => (11.0 + 0.1 * distance as f64, &path, folded_path), + None => continue, + } + } + None => continue, + }; + if !self.regex_accepts(file_id, &path, None)? { + continue; + } + // Mark the approximate matched span in the matched field for + // the GUI's [matched field] rendering. window_around clamps + // and aligns. + let snip = bitap.count_and_first(folded_field.as_bytes()).1.map(|range| { + snippet::window_around( + field, + range, + &snippet::Options { + approx_chars: field.len().saturating_mul(2).max(8), + }, + ) + }); + let is_path_tier = rank >= 11.0; + let hit = SearchHit { + file_id, + name, + path, + size: row.get::<_, i64>(3).map_err(|e| e.to_string())?.max(0) as u64, + mtime: row.get(4).map_err(|e| e.to_string())?, + rank, + stage: rank as u8, + snippet: snip, + }; + if is_path_tier { + path_buf.push(hit); + path_overflowed |= self.enforce_cap(&mut path_buf); + } else { + buf.push(hit); + overflowed |= self.enforce_cap(&mut buf); + } + } + drop(rows); + if self.cancelled() { + return Ok(false); + } + self.deferred_fuzzy_path = Deferred { + hits: path_buf, + overflowed: path_overflowed, + }; + self.flush_pass(buf, overflowed); + Ok(true) + } + + /// Pass D — rank 8, bitap over every stored document text. + fn pass_fuzzy_fulltext(&mut self) -> Result { + if !self.options.fuzzy { + return Ok(true); + } + // Bitap is a literal matcher; wildcard terms don't fuzz. + if self.query.pattern.is_wildcard() { + return Ok(true); + } + let folded_term = self.query.term.to_ascii_lowercase(); + let Some(k) = edit_budget(folded_term.len(), self.options.fuzzy_max_edits) else { + return Ok(true); + }; + let Some(bitap) = Bitap::new(folded_term.as_bytes(), k) else { + return Ok(true); + }; + + let sql = format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime, dt.text_zstd \ + FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}", + self.query.filter_sql + ); + let params = self.params_with_filters(Vec::new()); + let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; + let mut rows = stmt + .query(rusqlite::params_from_iter(params)) + .map_err(|e| e.to_string())?; + + let snippet_opts = snippet::Options { approx_chars: SNIPPET_WINDOW_CHARS }; + let mut buf: Vec = Vec::new(); + let mut overflowed = false; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + if self.cancelled() { + return Ok(false); + } + let file_id: i64 = row.get(0).map_err(|e| e.to_string())?; + let path: String = row.get(2).map_err(|e| e.to_string())?; + if self.skip(file_id, &path) { + continue; + } + let blob: Option> = row.get(5).map_err(|e| e.to_string())?; + let Some(blob) = blob else { + continue; + }; + let Ok(raw) = zstd::decode_all(blob.as_slice()) else { + continue; + }; + let text = String::from_utf8_lossy(&raw).into_owned(); + // ASCII folding is byte-length preserving, so ranges found in + // the folded buffer are valid in the original. + let folded = text.to_ascii_lowercase(); + let (count, first) = bitap.count_and_first(folded.as_bytes()); + if count == 0 { + continue; + } + if !self.regex_accepts(file_id, &path, Some(&text))? { + continue; + } + let snip = first.map(|range| snippet::window_around(&text, range, &snippet_opts)); + buf.push(SearchHit { + file_id, + name: row.get(1).map_err(|e| e.to_string())?, + path, + size: row.get::<_, i64>(3).map_err(|e| e.to_string())?.max(0) as u64, + mtime: row.get(4).map_err(|e| e.to_string())?, + rank: 8.0 + count_frac(count), + stage: 8, + snippet: snip, + }); + overflowed |= self.enforce_cap(&mut buf); + } + drop(rows); + if self.cancelled() { + return Ok(false); + } + self.flush_pass(buf, overflowed); + Ok(true) + } + + /// Regex-only pass over `files`: the regex bypasses the FTS trigram + /// entirely and runs on every name, falling back to the full path. + /// Name hits reuse rank 4, path hits defer to rank 10, so the GUI's + /// stage-based rendering needs no new cases. + fn pass_regex_name(&mut self) -> Result { + let query = self.query; + let re = query.regex.as_ref().expect("regex-only pass list"); + let sql = format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime FROM files f WHERE 1=1{}", + query.filter_sql + ); + let params = self.params_with_filters(Vec::new()); + let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; + let mut rows = stmt + .query(rusqlite::params_from_iter(params)) + .map_err(|e| e.to_string())?; + + let mut buf: Vec = Vec::new(); + let mut path_buf: Vec = Vec::new(); + let mut overflowed = false; + let mut path_overflowed = false; + let mut scanned = 0usize; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + scanned += 1; + if scanned % 1024 == 0 && self.cancelled() { + return Ok(false); + } + let file_id: i64 = row.get(0).map_err(|e| e.to_string())?; + let name: String = row.get(1).map_err(|e| e.to_string())?; + let path: String = row.get(2).map_err(|e| e.to_string())?; + if self.skip(file_id, &path) { + continue; + } + // The name is the better hit; only a name miss falls through + // to the path tier — mirroring pass A. + let (rank, match_range, is_path_tier) = match re.find_first(&name) { + Some(r) => (4.0, (r.start, r.end), false), + None => match re.find_first(&path) { + Some(r) => (10.0, (r.start, r.end), true), + None => continue, + }, + }; + let snip = snippet::Snippet { + ranges: vec![match_range], + window: if is_path_tier { path.clone() } else { name.clone() }, + truncated_start: false, + truncated_end: false, + }; + let hit = SearchHit { + file_id, + name, + path, + size: row.get::<_, i64>(3).map_err(|e| e.to_string())?.max(0) as u64, + mtime: row.get(4).map_err(|e| e.to_string())?, + rank, + stage: rank as u8, + snippet: Some(snip), + }; + if is_path_tier { + path_buf.push(hit); + path_overflowed |= self.enforce_cap(&mut path_buf); + } else { + buf.push(hit); + overflowed |= self.enforce_cap(&mut buf); + } + } + drop(rows); + if self.cancelled() { + return Ok(false); + } + self.deferred_path = Deferred { + hits: path_buf, + overflowed: path_overflowed, + }; + self.flush_pass(buf, overflowed); + Ok(true) + } + + /// Regex-only pass over every stored document text, reusing rank 6. + fn pass_regex_content(&mut self) -> Result { + let query = self.query; + let re = query.regex.as_ref().expect("regex-only pass list"); + let sql = format!( + "SELECT f.id, f.name, f.path, f.size, f.mtime, dt.text_zstd \ + FROM documents_text dt JOIN files f ON f.id = dt.file_id WHERE 1=1{}", + query.filter_sql + ); + let params = self.params_with_filters(Vec::new()); + let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?; + let mut rows = stmt + .query(rusqlite::params_from_iter(params)) + .map_err(|e| e.to_string())?; + + let snippet_opts = snippet::Options { approx_chars: SNIPPET_WINDOW_CHARS }; + let mut buf: Vec = Vec::new(); + let mut overflowed = false; + while let Some(row) = rows.next().map_err(|e| e.to_string())? { + // Decompression dominates: check every row. + if self.cancelled() { + return Ok(false); + } + let file_id: i64 = row.get(0).map_err(|e| e.to_string())?; + let path: String = row.get(2).map_err(|e| e.to_string())?; + if self.skip(file_id, &path) { + continue; + } + let blob: Option> = row.get(5).map_err(|e| e.to_string())?; + let Some(raw) = blob.and_then(|b| zstd::decode_all(b.as_slice()).ok()) else { + continue; + }; + let text = String::from_utf8_lossy(&raw).into_owned(); + let count = re.count(&text); + if count == 0 { + continue; + } + // A greedy user regex can match megabytes; clamp the range + // before the snippet window is cut. + let snip = re.find_first(&text).map(|r| { + let r = clamp_match_range(&text, r, SNIPPET_WINDOW_CHARS); + snippet::window_around(&text, (r.start, r.end), &snippet_opts) + }); + buf.push(SearchHit { + file_id, + name: row.get(1).map_err(|e| e.to_string())?, + path, + size: row.get::<_, i64>(3).map_err(|e| e.to_string())?.max(0) as u64, + mtime: row.get(4).map_err(|e| e.to_string())?, + rank: 6.0 + count_frac(count), + stage: 6, + snippet: snip, + }); + overflowed |= self.enforce_cap(&mut buf); + } + drop(rows); + if self.cancelled() { + return Ok(false); + } + self.flush_pass(buf, overflowed); + Ok(true) + } +} diff --git a/crates/quicksearch-core/src/search/duplicates.rs b/crates/quicksearch-core/src/search/duplicates.rs new file mode 100644 index 0000000..e47fe29 --- /dev/null +++ b/crates/quicksearch-core/src/search/duplicates.rs @@ -0,0 +1,175 @@ +//! Duplicate-file listing, grouped by content hash. Backs the GUI's +//! Duplicates tab; standalone and synchronous — callers run it on their +//! own worker thread. + +use rusqlite::params; + +use crate::db; + +#[derive(Debug, Clone, PartialEq)] +pub struct DuplicateGroup { + pub hash: Vec, + pub count: i64, + pub total_size: i64, + /// Bytes reclaimable by deduplicating: `size × (count - 1)` — the + /// group's sort key. + pub redundant_size: i64, + /// `(file_id, name, path, size, mtime)` per member, path-ordered. + pub members: Vec<(i64, String, String, u64, i64)>, +} + +/// Page through hash groups having more than one member, ordered by +/// reclaimable bytes (largest first). Rows with a NULL hash (never +/// hashed) and zero-size files (all trivially identical) are excluded. +pub fn find_duplicate_groups( + db_path: &str, + limit: u32, + offset: u32, +) -> Result, String> { + let conn = db::open_existing(db_path, false)?; + let mut groups: Vec = Vec::new(); + { + // SUM(size) - MAX(size) == size × (count - 1); members of a group + // share a size because the hash covers it. + let mut stmt = conn + .prepare( + "SELECT hash, COUNT(*) AS cnt, SUM(size), SUM(size) - MAX(size) AS redundant \ + FROM files \ + WHERE hash IS NOT NULL AND size > 0 \ + GROUP BY hash HAVING cnt > 1 \ + ORDER BY redundant DESC, hash \ + LIMIT ?1 OFFSET ?2", + ) + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![limit, offset], |r| { + Ok(DuplicateGroup { + hash: r.get(0)?, + count: r.get(1)?, + total_size: r.get(2)?, + redundant_size: r.get(3)?, + members: Vec::new(), + }) + }) + .map_err(|e| e.to_string())?; + for g in rows { + groups.push(g.map_err(|e| e.to_string())?); + } + } + + let mut member_stmt = conn + .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 + .query_map(params![group.hash], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, i64>(3)?.max(0) as u64, + r.get::<_, i64>(4)?, + )) + }) + .map_err(|e| e.to_string())?; + for m in rows { + group.members.push(m.map_err(|e| e.to_string())?); + } + } + + Ok(groups) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::open_or_recreate; + use crate::db::repo::{insert_file, NewFile}; + use crate::mime::FileType; + + fn seed_db() -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "qs-dups-{}-{}.sqlite", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); + let tx = conn.transaction().unwrap(); + let add = |name: &str, path: &str, size: u64, hash: Option<&[u8]>| { + insert_file( + &tx, + &NewFile { + name, + path, + parent: "/d", + size, + mtime: 1_700_000_000, + inode: None, + device_id: None, + mime: None, + ftype: FileType::TEXT, + hash, + }, + ) + .unwrap() + .expect("unique path"); + }; + // Triple group of small files: redundant = 10 × 2 = 20. + add("a1.txt", "/d/a1.txt", 10, Some(b"AAA")); + add("a2.txt", "/d/a2.txt", 10, Some(b"AAA")); + add("a3.txt", "/d/a3.txt", 10, Some(b"AAA")); + // Pair of large files: redundant = 100 × 1 = 100 — sorts first + // despite the smaller member count. + add("b1.txt", "/d/b1.txt", 100, Some(b"BBB")); + add("b2.txt", "/d/b2.txt", 100, Some(b"BBB")); + // Singletons and NULL hashes never appear. + add("c.txt", "/d/c.txt", 30, Some(b"CCC")); + add("n1.txt", "/d/n1.txt", 40, None); + add("n2.txt", "/d/n2.txt", 40, None); + // Zero-size files are trivially identical — excluded outright. + add("z1.txt", "/d/z1.txt", 0, Some(b"ZZZ")); + add("z2.txt", "/d/z2.txt", 0, Some(b"ZZZ")); + tx.commit().unwrap(); + drop(conn); + p + } + + #[test] + fn groups_ordered_by_redundant_size_zero_size_excluded() { + let p = seed_db(); + let groups = find_duplicate_groups(p.to_str().unwrap(), 10, 0).unwrap(); + assert_eq!( + groups.len(), + 2, + "singletons, NULL hashes, and zero-size groups excluded" + ); + // Reclaimable bytes beat member count for ordering. + assert_eq!(groups[0].count, 2); + assert_eq!(groups[0].total_size, 200); + assert_eq!(groups[0].redundant_size, 100); + assert_eq!(groups[1].count, 3); + assert_eq!(groups[1].redundant_size, 20); + assert_eq!(groups[1].members.len(), 3); + assert_eq!(groups[1].members[0].1, "a1.txt", "members path-ordered"); + std::fs::remove_file(&p).ok(); + } + + #[test] + fn pagination() { + let p = seed_db(); + let page1 = find_duplicate_groups(p.to_str().unwrap(), 1, 0).unwrap(); + let page2 = find_duplicate_groups(p.to_str().unwrap(), 1, 1).unwrap(); + assert_eq!(page1.len(), 1); + assert_eq!(page2.len(), 1); + assert_ne!(page1[0].hash, page2[0].hash); + let page3 = find_duplicate_groups(p.to_str().unwrap(), 1, 2).unwrap(); + assert!(page3.is_empty()); + std::fs::remove_file(&p).ok(); + } +} diff --git a/crates/quicksearch-core/src/search/fuzzy.rs b/crates/quicksearch-core/src/search/fuzzy.rs new file mode 100644 index 0000000..13e88c9 --- /dev/null +++ b/crates/quicksearch-core/src/search/fuzzy.rs @@ -0,0 +1,330 @@ +//! Approximate substring matching for the fuzzy cascade stages. +//! +//! Bitap (shift-and with errors, Wu–Manber): finds occurrences of a +//! pattern *within* a haystack with at most `k` Levenshtein edits +//! (insertion / deletion / substitution). Substring semantics are the +//! right fit for search-as-you-type — users type fragments, not whole +//! filenames — and the u64 bit-parallel update costs O(k) word ops per +//! haystack byte with zero allocations. +//! +//! Callers fold both sides to ASCII lowercase first (the pipeline-wide +//! convention). Patterns are limited to 64 bytes by the machine word; the +//! cascade skips fuzzy stages for longer terms. + +pub struct Bitap { + /// `masks[c]` has bit `i` set iff `pattern[i] == c`. + masks: [u64; 256], + /// Pattern length in bytes (1..=64). + len: usize, + /// Maximum edit distance. + k: usize, +} + +impl Bitap { + /// `None` when the pattern is empty, longer than 64 bytes, or the edit + /// budget reaches the word size (`initial_registers` shifts by `k`). + pub fn new(pattern: &[u8], k: usize) -> Option { + if pattern.is_empty() || pattern.len() > 64 || k >= 64 { + return None; + } + let mut masks = [0u64; 256]; + for (i, &b) in pattern.iter().enumerate() { + masks[b as usize] |= 1u64 << i; + } + Some(Bitap { + masks, + len: pattern.len(), + k, + }) + } + + /// Fresh per-distance state registers. Bit `i` of `r[d]` set means "a + /// match of pattern[..=i] with ≤ d errors ends at the current text + /// position". With d errors the first d pattern bytes can be deleted + /// before any text is read, hence the pre-set low bits. + fn initial_registers(&self) -> Vec { + (0..=self.k) + .map(|d| if d == 0 { 0 } else { (1u64 << d) - 1 }) + .collect() + } + + /// Advance all registers by one haystack byte. Returns the smallest + /// error count d for which the full pattern just matched, if any. + #[inline] + fn step(&self, r: &mut [u64], byte: u8) -> Option { + let mask = self.masks[byte as usize]; + 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. + r[0] = ((r[0] << 1) | 1) & mask; + if r[0] & done != 0 { + hit = Some(0); + } + for d in 1..=self.k { + let old = r[d]; + 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) + prev_old = old; + if hit.is_none() && r[d] & done != 0 { + hit = Some(d); + } + } + hit + } + + /// Minimum edit distance (≤ k) of any occurrence of the pattern in + /// `hay`, or `None` if nothing matches within k edits. + pub fn best_distance(&self, hay: &[u8]) -> Option { + let mut r = self.initial_registers(); + let mut best: Option = None; + for &b in hay { + if let Some(d) = self.step(&mut r, b) { + if d == 0 { + return Some(0); + } + if best.map_or(true, |cur| d < cur) { + best = Some(d); + } + } + } + best + } + + /// Count non-overlapping occurrences (at ≤ k edits) and report the + /// first match's approximate byte range in `hay`. After each hit the + /// automaton resets, so an exact match followed by trailing bytes + /// counts once, and overlapping suffix matches don't inflate counts. + /// The reported range assumes pattern-length matches — edits can shift + /// the true start by up to k bytes, which is fine for snippet windows. + pub fn count_and_first(&self, hay: &[u8]) -> (usize, Option<(usize, usize)>) { + let mut r = self.initial_registers(); + let mut count = 0usize; + let mut first: Option<(usize, usize)> = None; + for (i, &b) in hay.iter().enumerate() { + if self.step(&mut r, b).is_some() { + count += 1; + if first.is_none() { + let end = i + 1; + first = Some((end.saturating_sub(self.len), end)); + } + for (d, reg) in r.iter_mut().enumerate() { + *reg = if d == 0 { 0 } else { (1u64 << d) - 1 }; + } + } + } + (count, first) + } +} + +/// The cascade's edit-distance budget for a folded term: one edit per +/// three characters, capped by `[search].fuzzy_max_edits`. Terms outside +/// 3..=64 bytes skip the fuzzy stages entirely (< 3 is noise, > 64 exceeds +/// the word size), and a cap of 0 disables them everywhere. +/// +/// At the default cap of 2 this is the historic ladder: 3–5 bytes get one +/// edit, 6–64 get two. +pub fn edit_budget(term_len: usize, max_edits: usize) -> Option { + if !(3..=64).contains(&term_len) || max_edits == 0 { + return None; + } + Some((term_len / 3).min(max_edits)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn best(pattern: &str, hay: &str, k: usize) -> Option { + Bitap::new(pattern.as_bytes(), k) + .unwrap() + .best_distance(hay.as_bytes()) + } + + #[test] + fn exact_substring_is_distance_zero() { + assert_eq!(best("hello", "say hello world", 2), Some(0)); + assert_eq!(best("hello", "hello", 0), Some(0)); + } + + #[test] + fn single_edits_are_distance_one() { + assert_eq!(best("hello", "xx hxllo xx", 2), Some(1), "substitution"); + assert_eq!(best("hello", "xx helo xx", 2), Some(1), "deletion"); + assert_eq!(best("hello", "xx heXllo xx", 2), Some(1), "insertion"); + } + + #[test] + fn two_edits() { + assert_eq!(best("hello", "xx hxlo xx", 2), Some(2)); + assert_eq!(best("hello", "xx ho xx", 2), None, "3 edits > k"); + } + + #[test] + fn no_match_within_budget() { + assert_eq!(best("hello", "completely different", 1), None); + assert_eq!(best("abc", "", 1), None); + } + + #[test] + fn k_zero_is_exact_search() { + assert_eq!(best("abc", "xxabcxx", 0), Some(0)); + assert_eq!(best("abc", "xxabxcx", 0), None); + } + + #[test] + fn pattern_length_limits() { + assert!(Bitap::new(b"", 1).is_none()); + assert!(Bitap::new(&[b'a'; 65], 1).is_none()); + assert!(Bitap::new(&[b'a'; 64], 1).is_some()); + } + + #[test] + fn oversized_k_is_rejected_not_shifted() { + // `initial_registers` shifts by k; k >= 64 would overflow u64. + assert!(Bitap::new(b"abc", 64).is_none()); + assert!(Bitap::new(b"abc", usize::MAX).is_none()); + assert!(Bitap::new(b"abc", 63).is_some()); + } + + #[test] + fn count_non_overlapping() { + let b = Bitap::new(b"ab", 0).unwrap(); + let (count, first) = b.count_and_first(b"ab ab ab"); + assert_eq!(count, 3); + assert_eq!(first, Some((0, 2))); + + // "aaaa" contains "aaa" once non-overlapping. + let b = Bitap::new(b"aaa", 0).unwrap(); + let (count, _) = b.count_and_first(b"aaaa"); + assert_eq!(count, 1); + } + + #[test] + fn count_fuzzy_and_range_sane() { + let b = Bitap::new(b"hello", 1).unwrap(); + let hay = b"say helo and hxllo again"; + let (count, first) = b.count_and_first(hay); + assert_eq!(count, 2); + let (s, e) = first.unwrap(); + assert!(s < e && e <= hay.len()); + let window = &hay[s..e]; + assert!( + std::str::from_utf8(window).unwrap().contains("hel"), + "first range should cover the first hit, got {:?}", + std::str::from_utf8(window) + ); + } + + #[test] + fn edit_budget_default_cap_is_the_historic_ladder() { + assert_eq!(edit_budget(0, 2), None); + assert_eq!(edit_budget(2, 2), None); + assert_eq!(edit_budget(3, 2), Some(1)); + assert_eq!(edit_budget(5, 2), Some(1)); + assert_eq!(edit_budget(6, 2), Some(2)); + assert_eq!(edit_budget(64, 2), Some(2)); + assert_eq!(edit_budget(65, 2), None); + assert_eq!(edit_budget(usize::MAX, 2), None); + } + + #[test] + fn edit_budget_scales_with_length_up_to_the_cap() { + assert_eq!(edit_budget(3, 4), Some(1)); + assert_eq!(edit_budget(6, 4), Some(2)); + assert_eq!(edit_budget(9, 4), Some(3)); + assert_eq!(edit_budget(12, 4), Some(4)); + assert_eq!(edit_budget(64, 4), Some(4), "cap wins over length"); + } + + #[test] + fn edit_budget_cap_of_one_stays_strict() { + for len in 3..=64 { + assert_eq!(edit_budget(len, 1), Some(1)); + } + } + + #[test] + fn edit_budget_zero_disables_fuzzy() { + for len in 0..=70 { + assert_eq!(edit_budget(len, 0), None); + } + } + + /// Even a hostile config value can't produce a k the bitap rejects: + /// the length ladder caps it at 21 for the longest legal term. + #[test] + fn edit_budget_stays_within_the_bitap_word_size() { + for len in 3..=64 { + let k = edit_budget(len, usize::MAX).unwrap(); + assert!(k <= 21, "len={} gave k={}", len, k); + assert!(Bitap::new(&vec![b'a'; len], k).is_some()); + } + } + + /// Brute-force oracle: minimum Levenshtein distance between `pattern` + /// and any substring of `hay`, capped at k. + fn oracle(pattern: &[u8], hay: &[u8], k: usize) -> Option { + // An occurrence must end at some text position; empty text has + // none. Without this, k >= pattern-length "matches" empty text by + // deleting every pattern byte — a degenerate non-occurrence the + // automaton rightly never reports. (Production keeps k < len via + // the len/3 budget, so only the oracle ever saw this edge.) + if hay.is_empty() { + return None; + } + // Standard DP where row 0 is all zeros (match can start anywhere). + let m = pattern.len(); + let mut prev: Vec = vec![0; hay.len() + 1]; + let mut cur = vec![0; hay.len() + 1]; + let mut best = usize::MAX; + // dp[i][j] = min edits to match pattern[..i] ending at hay[..j] + for i in 1..=m { + cur[0] = i; + for j in 1..=hay.len() { + let cost = if pattern[i - 1] == hay[j - 1] { 0 } else { 1 }; + cur[j] = (prev[j - 1] + cost).min(prev[j] + 1).min(cur[j - 1] + 1); + } + std::mem::swap(&mut prev, &mut cur); + } + for j in 0..=hay.len() { + best = best.min(prev[j]); + } + if best <= k { + Some(best) + } else { + None + } + } + + #[test] + fn matches_brute_force_oracle() { + // 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 >> 33) as usize + }; + let alphabet = b"abcx"; + for _ in 0..500 { + let plen = 3 + rng() % 6; + let hlen = rng() % 20; + let pattern: Vec = (0..plen).map(|_| alphabet[rng() % 4]).collect(); + let hay: Vec = (0..hlen).map(|_| alphabet[rng() % 4]).collect(); + for k in 0..=4 { + let got = Bitap::new(&pattern, k).unwrap().best_distance(&hay); + let want = oracle(&pattern, &hay, k); + assert_eq!( + got, + want, + "pattern={:?} hay={:?} k={}", + std::str::from_utf8(&pattern), + std::str::from_utf8(&hay), + k + ); + } + } + } +} diff --git a/crates/quicksearch-core/src/search/mod.rs b/crates/quicksearch-core/src/search/mod.rs new file mode 100644 index 0000000..ad7c30e --- /dev/null +++ b/crates/quicksearch-core/src/search/mod.rs @@ -0,0 +1,307 @@ +//! Interruptible, streaming search service. +//! +//! One dedicated worker thread owns the cascade. The GUI (or any caller) +//! sends queries via [`SearchService::search`]; results stream back over +//! an mpsc receiver as [`SearchUpdate`] events tagged with a generation +//! number. Starting a new search bumps the generation and interrupts the +//! in-flight SQLite statement, so a keystroke never waits on the previous +//! query. +//! +//! Cancellation is two-layer: +//! - **cooperative** — the cascade compares its generation against the +//! latest every few hundred rows and stops silently when stale; +//! - **interrupt** — [`rusqlite::InterruptHandle::interrupt`] kills the +//! statement currently executing (covering the "no rows produced yet" +//! phases like FTS candidate gathering). An interrupted stale search is +//! normal cancellation, not an error. +//! +//! Consumers that want a plain blocking search (the CLI mode) skip the +//! service entirely and call [`cascade::run`] with a collecting sink. + +pub mod cascade; +pub mod duplicates; +pub mod fuzzy; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; + +use crate::db; +use crate::query::split::split_for_cascade; +use crate::snippet::Snippet; + +pub use cascade::Outcome; +pub use duplicates::{find_duplicate_groups, DuplicateGroup}; + +/// One search result. `rank` is the sort key (lower = better): integer +/// part = cascade stage (1–11), fraction = occurrence-count or +/// edit-distance tiebreak. Batches arrive already rank-ordered and later +/// batches only append, so a rank-sorted view never reshuffles. +#[derive(Debug, Clone)] +pub struct SearchHit { + pub file_id: i64, + pub name: String, + pub path: String, + pub size: u64, + pub mtime: i64, + pub rank: f64, + pub stage: u8, + /// The matched span in context: the filename for name stages, the full + /// path for path stages, a window of the body for full-text stages + /// (absent there when document text isn't stored). + pub snippet: Option, +} + +#[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 }, +} + +impl SearchUpdate { + pub fn generation(&self) -> u64 { + match self { + SearchUpdate::Started { generation } + | SearchUpdate::Hits { generation, .. } + | SearchUpdate::Completed { generation, .. } + | SearchUpdate::Error { generation, .. } => *generation, + } + } +} + +#[derive(Debug, Clone)] +pub struct SearchOptions { + /// Enable the fuzzy stages (ranks 7, 8 and 11). + pub fuzzy: bool, + /// Ceiling on the fuzzy edit budget (`[search].fuzzy_max_edits`); see + /// [`fuzzy::edit_budget`]. 0 disables the fuzzy stages. + pub fuzzy_max_edits: usize, + /// Hard cap on total hits per search (`[search].display_limit`). + pub limit: usize, + /// Streaming batch size (`[search].results_per_page`). + pub batch: usize, + /// Session-scoped ignore patterns (GUI chips), same glob semantics as + /// the config's `ignore_patterns`. Applied before the display cap. + pub session_ignores: Vec, +} + +impl Default for SearchOptions { + fn default() -> Self { + SearchOptions { + fuzzy: false, + fuzzy_max_edits: 2, + limit: 1000, + batch: 100, + session_ignores: Vec::new(), + } + } +} + +struct SearchRequest { + generation: u64, + input: String, + options: SearchOptions, +} + +pub struct SearchService { + req_tx: mpsc::Sender, + latest_gen: Arc, + interrupt: Arc>>, + db_path: Arc>, + handle: Option>, +} + +impl SearchService { + /// Spawn the worker. `notify` is invoked after every update event so + /// an egui frontend can `request_repaint` (pass a no-op for headless + /// use). Returns the service handle plus the update receiver, which + /// the caller drains non-blockingly. + pub fn new( + db_path: PathBuf, + notify: Arc, + ) -> (SearchService, mpsc::Receiver) { + let (req_tx, req_rx) = mpsc::channel::(); + let (update_tx, update_rx) = mpsc::channel::(); + let latest_gen = Arc::new(AtomicU64::new(0)); + let interrupt = Arc::new(Mutex::new(None)); + let db_path = Arc::new(Mutex::new(db_path)); + + let worker = Worker { + req_rx, + update_tx, + notify, + latest_gen: latest_gen.clone(), + interrupt: interrupt.clone(), + db_path: db_path.clone(), + }; + let handle = std::thread::Builder::new() + .name("qs-search".into()) + .spawn(move || worker.run()) + .expect("spawn search worker"); + + ( + SearchService { + req_tx, + latest_gen, + interrupt, + db_path, + handle: Some(handle), + }, + update_rx, + ) + } + + /// Start a new search, cancelling any in-flight one. Returns the + /// generation whose events to keep. + pub fn search(&self, input: &str, options: SearchOptions) -> u64 { + let generation = self.latest_gen.fetch_add(1, Ordering::SeqCst) + 1; + let _ = self.req_tx.send(SearchRequest { + generation, + input: input.to_string(), + options, + }); + // The new request can't be running yet (the worker hasn't dequeued + // it), so this only ever kills a stale generation's statement. + self.interrupt_current(); + generation + } + + /// Cancel without starting anything new. + pub fn cancel(&self) { + self.latest_gen.fetch_add(1, Ordering::SeqCst); + self.interrupt_current(); + } + + /// Point subsequent searches at a different index file. + pub fn set_db_path(&self, path: PathBuf) { + *self.db_path.lock().unwrap() = path; + self.cancel(); + } + + fn interrupt_current(&self) { + if let Ok(guard) = self.interrupt.lock() { + if let Some(handle) = guard.as_ref() { + handle.interrupt(); + } + } + } + + /// Cancel, close the request channel, and join the worker. + pub fn shutdown(self) { + self.cancel(); + let SearchService { req_tx, handle, .. } = self; + drop(req_tx); + if let Some(handle) = handle { + let _ = handle.join(); + } + } +} + +/// Map SQLite-level errors to the tagged strings frontends key off. +/// `DATABASE_CORRUPTED:` drives the GUI's recovery dialog. +pub fn classify_sql_err(error_msg: &str) -> String { + if error_msg.contains("malformed") + || error_msg.contains("corrupt") + || error_msg.contains("database disk image is malformed") + { + format!("DATABASE_CORRUPTED: {}", error_msg) + } else if error_msg.contains("fts5: syntax error") { + "Search syntax error: the search term contains characters that cannot be processed." + .to_string() + } else { + format!("Search failed: {}", error_msg) + } +} + +struct Worker { + req_rx: mpsc::Receiver, + update_tx: mpsc::Sender, + notify: Arc, + latest_gen: Arc, + interrupt: Arc>>, + db_path: Arc>, +} + +impl Worker { + fn run(self) { + while let Ok(first) = self.req_rx.recv() { + // A fast typist queues several requests; only the newest one + // matters. + let mut req = first; + while let Ok(newer) = self.req_rx.try_recv() { + req = newer; + } + if req.generation != self.latest_gen.load(Ordering::SeqCst) { + continue; + } + self.handle(req); + } + } + + fn send(&self, update: SearchUpdate) { + let _ = self.update_tx.send(update); + (self.notify)(); + } + + fn handle(&self, req: SearchRequest) { + let generation = req.generation; + self.send(SearchUpdate::Started { generation }); + + let split = match split_for_cascade(&req.input) { + Ok(s) => s, + Err(e) => { + self.send(SearchUpdate::Error { + generation, + message: e.to_string(), + }); + return; + } + }; + + let db_path = self.db_path.lock().unwrap().clone(); + // Per-request open: microseconds, and always sees a freshly + // rebuilt index file rather than pinning a deleted inode. + let conn = match db::open_existing(&db_path.to_string_lossy(), false) { + Ok(c) => c, + Err(e) => { + self.send(SearchUpdate::Error { + generation, + message: classify_sql_err(&e), + }); + return; + } + }; + *self.interrupt.lock().unwrap() = Some(conn.get_interrupt_handle()); + + let mut sink = |hits: Vec| { + self.send(SearchUpdate::Hits { generation, hits }); + }; + let outcome = cascade::run( + &conn, + &split, + &req.options, + generation, + &self.latest_gen, + &mut sink, + ); + + *self.interrupt.lock().unwrap() = None; + + match outcome { + Ok(Some(Outcome { total, limited })) => self.send(SearchUpdate::Completed { + generation, + total, + limited, + }), + // Cancelled — the newer generation owns the UI now. + Ok(None) => {} + Err(e) => self.send(SearchUpdate::Error { + generation, + message: classify_sql_err(&e), + }), + } + } +} diff --git a/crates/quicksearch-core/src/search_sql.rs b/crates/quicksearch-core/src/search_sql.rs deleted file mode 100644 index 4087f45..0000000 --- a/crates/quicksearch-core/src/search_sql.rs +++ /dev/null @@ -1,396 +0,0 @@ -//! SQL builders for the GUI's three legacy search modes (full-text, -//! filename, duplicate). Lives in core so it has unit-test coverage; the -//! GUI just composes these into per-page queries. -//! -//! For the structured Baloo-subset query language (`type:`, `modified:`, -//! …), see [`crate::query`]. - -/// All inputs needed to run (and re-run) one of the three search modes. -/// Cached after a fresh search so paging buttons don't have to rebuild -/// from form state. -#[derive(Debug, Clone, PartialEq)] -pub struct SearchArgs { - pub search_type: String, - pub term: String, - pub fulltext_exact: bool, - pub fulltext_case_sensitive: bool, -} - -/// SQL that counts every row matching `args`, ignoring pagination. Used to -/// drive the "page X of Y" UI. For very large FTS hit sets this can take a -/// noticeable fraction of the per-page query time, but it only runs on a -/// fresh search — page navigation reuses the cached total. -pub fn build_count(args: &SearchArgs) -> Result { - match args.search_type.as_str() { - "fulltext" => { - let where_clause = build_fulltext_where(args)?; - Ok(format!( - "SELECT COUNT(*) FROM searchabletext AS st WHERE {}", - where_clause - )) - } - "filename" => { - if args.term.trim().is_empty() { - return Err("Please enter a filename pattern".into()); - } - Ok(format!( - "SELECT COUNT(*) FROM files WHERE name LIKE '%{}%'", - args.term.replace('\'', "''") - )) - } - "duplicates" => Ok( - "SELECT COUNT(*) FROM (SELECT 1 FROM files WHERE hash IS NOT NULL \ - GROUP BY hash HAVING count(*) > 1)" - .into(), - ), - _ => Err("Unknown search type".into()), - } -} - -/// SQL that returns one page of results. Columns emitted by the `fulltext` -/// branch are `(name, path, file_id, text_zstd)` — the snippet is rendered -/// in Rust from the zstd-compressed `documents_text` row (FTS5 is -/// contentless, so SQLite's `snippet()` doesn't work on it). The GUI -/// should use [`crate::indexing::IndexingService::execute_fulltext_search`] -/// which stitches the decompress + snippet step on top of this SQL. -pub fn build_select(args: &SearchArgs, limit: u32, offset: u32) -> Result { - match args.search_type.as_str() { - "fulltext" => { - let where_clause = build_fulltext_where(args)?; - Ok(format!( - "SELECT f.name, f.path, f.id, dt.text_zstd \ - FROM searchabletext AS st \ - JOIN files f ON f.id = st.rowid \ - LEFT JOIN documents_text dt ON dt.file_id = f.id \ - WHERE {} ORDER BY rank LIMIT {} OFFSET {}", - where_clause, limit, offset - )) - } - "filename" => { - if args.term.trim().is_empty() { - return Err("Please enter a filename pattern".into()); - } - Ok(format!( - "SELECT name, path FROM files WHERE name LIKE '%{}%' ORDER BY name LIMIT {} OFFSET {}", - args.term.replace('\'', "''"), - limit, - offset - )) - } - "duplicates" => Ok(format!( - "SELECT name, count(*) as cnt, path FROM files WHERE hash IS NOT NULL \ - GROUP BY hash HAVING cnt > 1 ORDER BY cnt DESC LIMIT {} OFFSET {}", - limit, offset - )), - _ => Err("Unknown search type".into()), - } -} - -/// Translate the user-typed term into the FTS5 `MATCH` expression and any -/// supplemental case-sensitivity filters. Shared by count and select so -/// pagination doesn't accidentally diverge from the totals. -fn build_fulltext_where(args: &SearchArgs) -> Result { - let trimmed = args.term.trim(); - if trimmed.is_empty() { - return Err("Please enter a search term".into()); - } - - // Strip FTS5 control characters that confuse the parser. Replace with - // spaces so word boundaries survive. - let sanitized: String = trimmed - .chars() - .map(|c| { - if matches!( - c, - ':' | ';' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '~' | '"' - ) { - ' ' - } else { - c - } - }) - .collect(); - - let tokens: Vec<&str> = sanitized.split_whitespace().collect(); - if tokens.is_empty() { - return Err("Please enter a valid search term".into()); - } - - // Trigram tokenizer needs each word to be at least 3 characters. Exact - // phrase mode skips this filter — a quoted phrase of short words still - // matches because the trigrams overlap the spaces between words. - let words: Vec<&str> = if args.fulltext_exact { - tokens - } else { - let filtered: Vec<&str> = tokens - .into_iter() - .filter(|w| w.chars().count() >= 3) - .collect(); - if filtered.is_empty() { - return Err( - "Trigram index needs each word to be at least 3 characters unless you use exact phrase search." - .into(), - ); - } - filtered - }; - - let sql_quote = |s: &str| s.replace('\'', "''"); - - let fts_match = if args.fulltext_exact { - let phrase = words.join(" "); - format!("\"{}\"", phrase.replace('"', "\"\"")) - } else { - words.join(" AND ") - }; - - // Contentless FTS5 doesn't store column text, so case-sensitive - // filtering can't live in SQL anymore. It's re-applied in - // `IndexingService::execute_fulltext_search` by checking the - // decompressed body text for literal-case matches before returning - // the row. The MATCH itself stays case-insensitive (tokenizer folds), - // which is the correct candidate-set for a post-filter. - let where_clause = format!("st.text MATCH '{}'", sql_quote(&fts_match)); - Ok(where_clause) -} - -/// Pull out the raw words the user typed so the snippet renderer and the -/// case-sensitive post-filter can see the same tokens `build_fulltext_where` -/// fed into FTS5. Returns empty when the user's term is empty or contains -/// only too-short words under the non-exact path. -pub fn fulltext_terms(args: &SearchArgs) -> Vec { - let trimmed = args.term.trim(); - if trimmed.is_empty() { - return Vec::new(); - } - let sanitized: String = trimmed - .chars() - .map(|c| { - if matches!( - c, - ':' | ';' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '~' | '"' - ) { - ' ' - } else { - c - } - }) - .collect(); - let tokens: Vec = sanitized - .split_whitespace() - .map(|s| s.to_string()) - .collect(); - if tokens.is_empty() { - return Vec::new(); - } - if args.fulltext_exact { - // One composite phrase. Snippet rendering wants to highlight the - // whole phrase contiguously; the renderer supports multiple terms - // already so we collapse to the joined form. - vec![tokens.join(" ")] - } else { - tokens - .into_iter() - .filter(|w| w.chars().count() >= 3) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::db::{open_or_recreate, repo::{insert_file, set_content_done, NewFile}}; - use crate::mime::FileType; - - fn args(search_type: &str, term: &str) -> SearchArgs { - SearchArgs { - search_type: search_type.into(), - term: term.into(), - fulltext_exact: false, - fulltext_case_sensitive: false, - } - } - - #[test] - fn fulltext_select_has_limit_and_offset() { - let sql = build_select(&args("fulltext", "hello world"), 50, 100).unwrap(); - assert!(sql.contains("LIMIT 50")); - assert!(sql.contains("OFFSET 100")); - assert!(sql.contains("ORDER BY rank")); - // Snippet rendering moved to Rust; SQL returns the compressed blob - // so the post-processor can decompress + highlight. - assert!(sql.contains("dt.text_zstd"), "got {sql}"); - } - - #[test] - fn fulltext_case_sensitive_no_longer_in_sql() { - let mut a = args("fulltext", "Hello"); - a.fulltext_case_sensitive = true; - let sql = build_select(&a, 50, 0).unwrap(); - // Post-filter lives in Rust now — no instr() or st.text reference. - assert!(!sql.contains("instr("), "got {sql}"); - } - - #[test] - fn fulltext_terms_extract_non_exact() { - let a = args("fulltext", "the quick brown"); - let t = fulltext_terms(&a); - // Short words like "the" are dropped (trigram min length 3 applies - // in non-exact mode — matches the SQL build rules). - assert!(t.iter().any(|s| s == "quick")); - assert!(t.iter().any(|s| s == "brown")); - } - - #[test] - fn fulltext_terms_exact_mode_returns_joined_phrase() { - let mut a = args("fulltext", "hello world"); - a.fulltext_exact = true; - let t = fulltext_terms(&a); - assert_eq!(t, vec!["hello world".to_string()]); - } - - #[test] - fn fulltext_count_lacks_pagination_and_join() { - let sql = build_count(&args("fulltext", "hello world")).unwrap(); - assert!(sql.starts_with("SELECT COUNT(*)")); - assert!(!sql.contains("LIMIT")); - assert!(!sql.contains("OFFSET")); - assert!(!sql.contains("JOIN files")); - } - - #[test] - fn fulltext_short_words_filtered_unless_exact() { - let err = build_select(&args("fulltext", "a b"), 50, 0).unwrap_err(); - assert!(err.contains("3 characters")); - let mut a = args("fulltext", "a b"); - a.fulltext_exact = true; - assert!(build_select(&a, 50, 0).is_ok()); - } - - #[test] - fn fulltext_quotes_are_escaped() { - let sql = build_select(&args("fulltext", "it's working"), 50, 0).unwrap(); - // SQL literals double single quotes. - assert!(sql.contains("it''s")); - } - - #[test] - fn filename_select_has_limit_offset_and_order() { - let sql = build_select(&args("filename", "report"), 50, 0).unwrap(); - assert!(sql.contains("LIMIT 50")); - assert!(sql.contains("OFFSET 0")); - assert!(sql.contains("ORDER BY name")); - assert!(sql.contains("name LIKE '%report%'")); - } - - #[test] - fn filename_empty_term_errors() { - assert!(build_select(&args("filename", " "), 50, 0).is_err()); - assert!(build_count(&args("filename", "")).is_err()); - } - - #[test] - fn duplicates_select_has_limit_offset() { - let sql = build_select(&args("duplicates", ""), 50, 100).unwrap(); - assert!(sql.contains("LIMIT 50")); - assert!(sql.contains("OFFSET 100")); - assert!(sql.contains("GROUP BY hash")); - } - - #[test] - fn unknown_search_type_errors() { - assert!(build_select(&args("nope", ""), 50, 0).is_err()); - assert!(build_count(&args("nope", "")).is_err()); - } - - fn tmp_path() -> std::path::PathBuf { - let mut p = std::env::temp_dir(); - p.push(format!( - "qs-search-sql-{}-{}.sqlite", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - p - } - - /// End-to-end: seed three rows, run count + paged select, verify - /// pagination boundaries actually behave on a real DB. - #[test] - fn end_to_end_pagination_smoke() { - let p = tmp_path(); - let mut conn = open_or_recreate(p.to_str().unwrap(), "trigram").unwrap(); - { - let tx = conn.transaction().unwrap(); - for i in 0..7 { - let path = format!("/tmp/file_{}.txt", i); - let id = insert_file( - &tx, - &NewFile { - name: &format!("file_{}.txt", i), - path: &path, - parent: "/tmp", - size: 1, - mtime: 1, - inode: None, - device_id: None, - mime: Some("text/plain"), - ftype: FileType::TEXT, - hash: None, - }, - ) - .unwrap() - .expect("unique path"); - set_content_done(&tx, id, &format!("file_{}.txt", i), "shared body content", &[], true).unwrap(); - } - tx.commit().unwrap(); - } - - // Count: all 7 rows match "shared". - let count_sql = build_count(&args("fulltext", "shared body content")).unwrap(); - let n: i64 = conn.query_row(&count_sql, [], |r| r.get(0)).unwrap(); - assert_eq!(n, 7); - - // Page 1, page_size 3 → 3 rows. - let sel1 = build_select(&args("fulltext", "shared body content"), 3, 0).unwrap(); - let rows1: Vec = conn - .prepare(&sel1) - .unwrap() - .query_map([], |r| r.get::<_, String>(1)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - assert_eq!(rows1.len(), 3); - - // Page 3, page_size 3 → 1 row (offset 6, 7 total). - let sel3 = build_select(&args("fulltext", "shared body content"), 3, 6).unwrap(); - let rows3: Vec = conn - .prepare(&sel3) - .unwrap() - .query_map([], |r| r.get::<_, String>(1)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - assert_eq!(rows3.len(), 1); - - // Filename pagination on the same fixture. - let fn_count = build_count(&args("filename", "file_")).unwrap(); - let n2: i64 = conn.query_row(&fn_count, [], |r| r.get(0)).unwrap(); - assert_eq!(n2, 7); - - let fn_sel = build_select(&args("filename", "file_"), 5, 0).unwrap(); - let rows: Vec = conn - .prepare(&fn_sel) - .unwrap() - .query_map([], |r| r.get::<_, String>(1)) - .unwrap() - .map(|r| r.unwrap()) - .collect(); - assert_eq!(rows.len(), 5); - - drop(conn); - std::fs::remove_file(&p).ok(); - } -} diff --git a/crates/quicksearch-core/src/shutdown.rs b/crates/quicksearch-core/src/shutdown.rs index 2a48a60..2144d17 100644 --- a/crates/quicksearch-core/src/shutdown.rs +++ b/crates/quicksearch-core/src/shutdown.rs @@ -1,26 +1,23 @@ //! Process-level shutdown helpers. //! -//! Wires Ctrl-C (and on Unix, SIGTERM) to a graceful shutdown that flushes -//! the indexing DB and exits. Replaces the ad-hoc `ctrlc::set_handler` + -//! `OnceLock` dance the GUI used to carry. Call -//! [`install_signal_handler`] once from a binary's `main` with a cloned -//! [`IndexingService`] handle. +//! Wires Ctrl-C (and on Unix, SIGTERM) to a graceful shutdown that stops +//! the watcher, aborts any running index pass, flushes the WAL, and +//! exits. Call [`install_signal_handler`] once from a binary's `main` +//! with a cloned [`IndexCoordinator`] handle. use std::sync::Arc; -use crate::indexing::IndexingService; +use crate::coordinator::IndexCoordinator; /// Install a Ctrl-C (and, where supported, SIGTERM) handler that calls -/// [`IndexingService::graceful_shutdown`] and then exits with status 0. +/// [`IndexCoordinator::shutdown`] and then exits with status 0. /// /// Returns an error only if a handler was already installed elsewhere in /// this process (ctrlc::set_handler is one-shot). -pub fn install_signal_handler(service: Arc) -> Result<(), String> { +pub fn install_signal_handler(coordinator: Arc) -> Result<(), String> { ctrlc::set_handler(move || { - eprintln!("Received Ctrl-C, shutting down gracefully..."); - if let Err(e) = service.graceful_shutdown() { - eprintln!("Error during graceful shutdown: {}", e); - } + crate::log_info!("Received Ctrl-C, shutting down gracefully..."); + coordinator.shutdown(); std::process::exit(0); }) .map_err(|e| format!("install signal handler: {}", e)) diff --git a/crates/quicksearch-core/src/snippet.rs b/crates/quicksearch-core/src/snippet.rs index 0767267..b67f1dc 100644 --- a/crates/quicksearch-core/src/snippet.rs +++ b/crates/quicksearch-core/src/snippet.rs @@ -1,60 +1,70 @@ -//! Snippet / highlight rendering for search results. +//! Snippet extraction for search results. //! //! We store extracted text in the `documents_text` sidecar (zstd-compressed) //! rather than in FTS5, so SQLite's built-in `snippet()` / `highlight()` //! auxiliary functions aren't available (contentless FTS5 doesn't support -//! them). This module reproduces the parts we actually need in Rust: find -//! a window of context around the first match, bold every query-term -//! occurrence inside that window, trim with ellipsis markers. +//! them). This module reproduces the parts we actually need in Rust: find a +//! window of context around the first match and report every query-term +//! occurrence inside that window. //! -//! Matching is ASCII-case-insensitive on the *rendering* side. That aligns -//! with the search path which is already case-insensitive via the trigram -//! tokenizer; exact-case-only snippets aren't a feature users expect here. -//! Unicode accent folding isn't applied at the rendering layer — a query -//! for `cafe` will still *find* a file containing `café` (because the FTS -//! tokenizer strips diacritics) but the snippet won't highlight the -//! accented occurrence. The surrounding text is still returned verbatim. +//! Output is *structural* — the window text plus byte ranges of the matches +//! within it — so any frontend can render highlights natively (egui builds +//! a `LayoutJob`, the CLI emits ANSI bold). Nothing here produces markup. //! -//! The API is intentionally small: one `render` function plus an `Options` -//! struct. Callers that want different pre/post tags, ellipsis, or window -//! size pass them in; there are sensible defaults for the GUI case. +//! Matching is ASCII-case-insensitive. That aligns with the search path, +//! which is already case-insensitive via the trigram tokenizer; Unicode +//! accent folding isn't applied at this layer — a query for `cafe` will +//! still *find* a file containing `café` (the FTS tokenizer strips +//! diacritics) but the snippet won't mark the accented occurrence. The +//! window text is returned verbatim either way. -/// Options controlling snippet rendering. The defaults mirror the old -/// `snippet(searchabletext, 1, '', '', '...', 64)` call that -/// the GUI used to run directly as SQL. +/// Options controlling snippet extraction. #[derive(Debug, Clone)] -pub struct Options<'a> { - pub pre: &'a str, - pub post: &'a str, - pub ellipsis: &'a str, - /// Approximate character budget for the returned snippet. Matches - /// expand the window if needed to keep their tags on; the budget is a - /// soft target, not a hard cap. +pub struct Options { + /// Approximate byte budget for the returned window. Matches expand the + /// window if needed so a hit is never cut off; the budget is a soft + /// target, not a hard cap. pub approx_chars: usize, } -impl<'a> Default for Options<'a> { +impl Default for Options { fn default() -> Self { - Options { - pre: "", - post: "", - ellipsis: "...", - approx_chars: 200, + Options { approx_chars: 200 } + } +} + +/// A context window from a document plus the match positions inside it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Snippet { + /// The excerpt, sliced verbatim from the source text on UTF-8 char + /// boundaries. + pub window: String, + /// Byte ranges *into `window`*, sorted, non-overlapping (overlapping + /// term hits are coalesced), always on char boundaries. + pub ranges: Vec<(usize, usize)>, + /// Content exists before/after the window — frontends render their own + /// ellipsis. + pub truncated_start: bool, + pub truncated_end: bool, +} + +impl Snippet { + fn empty() -> Snippet { + Snippet { + window: String::new(), + ranges: Vec::new(), + truncated_start: false, + truncated_end: false, } } } -/// Render a snippet from `text` highlighting every occurrence of any term -/// in `terms`. Returns a string with `pre`/`post` wrapping each match, and -/// `ellipsis` prepended/appended when the returned window doesn't reach -/// the text's edges. -/// -/// If no term matches, returns the first `approx_chars` of `text` (char- -/// aligned), suffixed with `ellipsis` when truncated. -pub fn render(text: &str, terms: &[&str], opts: &Options<'_>) -> String { - // Short-circuit trivial inputs. +/// Extract a snippet from `text` marking every occurrence of any term in +/// `terms` (ASCII-case-insensitive). With no terms or no matches, returns +/// the head of the text as the window with no ranges. +pub fn extract(text: &str, terms: &[&str], opts: &Options) -> Snippet { if text.is_empty() { - return String::new(); + return Snippet::empty(); } let effective_terms: Vec<&str> = terms .iter() @@ -62,32 +72,24 @@ pub fn render(text: &str, terms: &[&str], opts: &Options<'_>) -> String { .filter(|t| !t.is_empty()) .collect(); if effective_terms.is_empty() { - return truncate_head(text, opts.approx_chars, opts.ellipsis); + return head_window(text, opts.approx_chars); } - // Case-fold once; we do all positioning on the folded buffer and emit - // slices from the original. Both buffers have identical byte layout - // because `to_ascii_lowercase` is a byte-for-byte map that preserves - // multi-byte UTF-8 sequences unchanged (it only touches ASCII letters). + // Case-fold once; we do all positioning on the folded buffer and slice + // from the original. Both buffers have identical byte layout because + // `to_ascii_lowercase` only touches ASCII letters. let folded = text.to_ascii_lowercase(); let folded_bytes = folded.as_bytes(); let mut matches: Vec<(usize, usize)> = Vec::new(); for term in &effective_terms { - let pattern: String = term.to_ascii_lowercase(); + let pattern = term.to_ascii_lowercase(); let pbytes = pattern.as_bytes(); - if pbytes.is_empty() { - continue; - } let mut start = 0; while start + pbytes.len() <= folded_bytes.len() { if let Some(rel) = memfind(&folded_bytes[start..], pbytes) { let at = start + rel; matches.push((at, at + pbytes.len())); - // Advance past this match to avoid zero-width loops on - // empty patterns (already guarded above) and to allow - // overlapping matches of *different* terms in the next - // outer-loop iteration. start = at + pbytes.len(); } else { break; @@ -96,24 +98,18 @@ pub fn render(text: &str, terms: &[&str], opts: &Options<'_>) -> String { } if matches.is_empty() { - return truncate_head(text, opts.approx_chars, opts.ellipsis); + return head_window(text, opts.approx_chars); } - // Dedupe + sort so overlapping matches from different terms (e.g. - // "rust" and "rustc") don't produce nested tags. matches.sort_by_key(|(a, _)| *a); - matches = coalesce_overlapping(matches); + let matches = coalesce_overlapping(matches); // Pick the window. Start a third of the budget before the first match - // so the hit isn't pinned to the left edge. Round both ends to char + // so the hit isn't pinned to the left edge; round both ends to char // boundaries so we never slice a multi-byte UTF-8 sequence. let pre_pad = opts.approx_chars / 3; - let first_match_start = matches[0].0; - let mut win_start = first_match_start.saturating_sub(pre_pad); - let mut win_end = win_start + opts.approx_chars; - if win_end > text.len() { - win_end = text.len(); - } + let mut win_start = matches[0].0.saturating_sub(pre_pad); + let mut win_end = (win_start + opts.approx_chars).min(text.len()); while win_start > 0 && !text.is_char_boundary(win_start) { win_start -= 1; } @@ -121,11 +117,9 @@ pub fn render(text: &str, terms: &[&str], opts: &Options<'_>) -> String { win_end += 1; } - // Expand the window to include the full end of any match that would - // otherwise be cut off mid-tag. Keeps rendering sane when a long term - // sits at the right edge of the budget. - let last_match_in_window = matches.iter().rfind(|(s, _)| *s < win_end); - if let Some((_, end)) = last_match_in_window { + // Expand the window so a match straddling the right edge is fully + // included rather than cut mid-hit. + if let Some((_, end)) = matches.iter().rfind(|(s, _)| *s < win_end) { if *end > win_end { win_end = *end; while win_end < text.len() && !text.is_char_boundary(win_end) { @@ -134,55 +128,114 @@ pub fn render(text: &str, terms: &[&str], opts: &Options<'_>) -> String { } } - // Render: walk matches that fall inside the window, splicing pre/post - // around each. Prepend/append ellipsis when we've chopped off content. - let mut out = String::with_capacity(win_end - win_start + 32); - if win_start > 0 { - out.push_str(opts.ellipsis); + 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)) + .collect(); + + Snippet { + window: text[win_start..win_end].to_string(), + ranges, + truncated_start: win_start > 0, + truncated_end: win_end < text.len(), } - let mut cursor = win_start; - for (ms, me) in matches.iter() { - if *me <= win_start || *ms >= win_end { - continue; - } - // Clamp to the window. - let ms = (*ms).max(win_start); - let me = (*me).min(win_end); - if ms > cursor { - out.push_str(&text[cursor..ms]); - } - out.push_str(opts.pre); - out.push_str(&text[ms..me]); - out.push_str(opts.post); - cursor = me; - } - if cursor < win_end { - out.push_str(&text[cursor..win_end]); - } - if win_end < text.len() { - out.push_str(opts.ellipsis); - } - out } -/// Return the first `n` characters of `text`, suffixed with `ellipsis` if -/// truncation actually happened. Respects UTF-8 char boundaries. -fn truncate_head(text: &str, n: usize, ellipsis: &str) -> String { +/// Build a snippet window around one known match range in `text` (byte +/// offsets into `text`). Used by fuzzy full-text search, where the match +/// was located by the fuzzy matcher rather than exact term search. The +/// range is clamped and char-boundary-aligned defensively. +pub fn window_around(text: &str, range: (usize, usize), opts: &Options) -> Snippet { + if text.is_empty() { + return Snippet::empty(); + } + let (mut ms, mut me) = range; + ms = ms.min(text.len()); + me = me.clamp(ms, text.len()); + while ms > 0 && !text.is_char_boundary(ms) { + ms -= 1; + } + while me < text.len() && !text.is_char_boundary(me) { + me += 1; + } + + let pre_pad = opts.approx_chars / 3; + let mut win_start = ms.saturating_sub(pre_pad); + let mut win_end = (win_start + opts.approx_chars).max(me).min(text.len()); + while win_start > 0 && !text.is_char_boundary(win_start) { + win_start -= 1; + } + while win_end < text.len() && !text.is_char_boundary(win_end) { + win_end += 1; + } + + let ranges = if me > ms { + vec![(ms - win_start, me - win_start)] + } else { + Vec::new() + }; + Snippet { + window: text[win_start..win_end].to_string(), + ranges, + truncated_start: win_start > 0, + truncated_end: win_end < text.len(), + } +} + +/// Count non-overlapping occurrences of `term` in `text`. Empty terms count +/// zero. Case-insensitive counting folds ASCII only, matching the rest of +/// the search pipeline. +pub fn count_occurrences(text: &str, term: &str, case_sensitive: bool) -> usize { + if term.is_empty() || text.len() < term.len() { + return 0; + } + let (hay, needle); + let (hay_ref, needle_ref): (&[u8], &[u8]) = if case_sensitive { + (text.as_bytes(), term.as_bytes()) + } else { + hay = text.to_ascii_lowercase(); + needle = term.to_ascii_lowercase(); + (hay.as_bytes(), needle.as_bytes()) + }; + let mut count = 0; + let mut start = 0; + while start + needle_ref.len() <= hay_ref.len() { + match memfind(&hay_ref[start..], needle_ref) { + Some(rel) => { + count += 1; + start += rel + needle_ref.len(); + } + None => break, + } + } + count +} + +/// The first `n` bytes of `text` (char-aligned) as a match-less window. +fn head_window(text: &str, n: usize) -> Snippet { if text.len() <= n { - return text.to_string(); + return Snippet { + window: text.to_string(), + ranges: Vec::new(), + truncated_start: false, + truncated_end: false, + }; } let mut cut = n; while cut > 0 && !text.is_char_boundary(cut) { cut -= 1; } - let mut out = String::with_capacity(cut + ellipsis.len()); - out.push_str(&text[..cut]); - out.push_str(ellipsis); - out + Snippet { + window: text[..cut].to_string(), + ranges: Vec::new(), + truncated_start: false, + truncated_end: true, + } } -/// Merge adjacent / overlapping (start, end) ranges in place. Input must be -/// sorted by start. +/// Merge adjacent / overlapping (start, end) ranges. Input must be sorted +/// by start. fn coalesce_overlapping(mut v: Vec<(usize, usize)>) -> Vec<(usize, usize)> { if v.len() < 2 { return v; @@ -202,7 +255,7 @@ fn coalesce_overlapping(mut v: Vec<(usize, usize)>) -> Vec<(usize, usize)> { } /// Locate the first occurrence of `needle` in `hay`. A byte-level search; -/// callers have already lowercased both sides so case is normalized. +/// callers have already normalized case where needed. fn memfind(hay: &[u8], needle: &[u8]) -> Option { if needle.is_empty() || needle.len() > hay.len() { return None; @@ -222,115 +275,170 @@ fn memfind(hay: &[u8], needle: &[u8]) -> Option { mod tests { use super::*; - fn opts_small() -> Options<'static> { - Options { - pre: "", - post: "", - ellipsis: "…", - approx_chars: 40, + fn opts_small() -> Options { + Options { approx_chars: 40 } + } + + /// Every range must be in-bounds, ordered, non-overlapping, and sit on + /// char boundaries — the contract egui's LayoutJob sections rely on. + fn assert_ranges_valid(s: &Snippet) { + let mut prev_end = 0; + for &(a, b) in &s.ranges { + assert!(a < b, "empty/inverted range {:?}", (a, b)); + assert!(b <= s.window.len(), "range {:?} beyond window", (a, b)); + assert!(a >= prev_end, "overlapping ranges"); + assert!(s.window.is_char_boundary(a) && s.window.is_char_boundary(b)); + prev_end = b; } } + fn marked(s: &Snippet) -> Vec<&str> { + s.ranges.iter().map(|&(a, b)| &s.window[a..b]).collect() + } + #[test] fn empty_text_returns_empty() { - let s = render("", &["foo"], &Options::default()); - assert_eq!(s, ""); + let s = extract("", &["foo"], &Options::default()); + assert_eq!(s, Snippet::empty()); } #[test] - fn no_terms_returns_head_with_ellipsis_when_truncated() { + fn no_terms_returns_head_marked_truncated() { let long = "abcdefghijklmnop".repeat(10); - let s = render(&long, &[], &opts_small()); - assert!(s.ends_with("…")); - assert!(s.len() < long.len() + 4); + let s = extract(&long, &[], &opts_small()); + assert!(s.truncated_end); + assert!(!s.truncated_start); + assert!(s.ranges.is_empty()); + assert!(s.window.len() <= 40); } #[test] - fn no_terms_untruncated_has_no_ellipsis() { - let s = render("short text", &[], &opts_small()); - assert_eq!(s, "short text"); + fn no_terms_untruncated() { + let s = extract("short text", &[], &opts_small()); + assert_eq!(s.window, "short text"); + assert!(!s.truncated_end && !s.truncated_start); } #[test] - fn simple_highlight_wraps_matches() { - let s = render("the quick brown fox", &["quick"], &opts_small()); - assert!(s.contains("quick")); + fn simple_match_range() { + let s = extract("the quick brown fox", &["quick"], &opts_small()); + assert_eq!(marked(&s), vec!["quick"]); + assert_ranges_valid(&s); } #[test] - fn case_insensitive_match() { - let s = render("The QUICK brown fox", &["quick"], &opts_small()); - assert!(s.contains("QUICK"), "got {s}"); + fn case_insensitive_match_reports_original_case() { + let s = extract("The QUICK brown fox", &["quick"], &opts_small()); + assert_eq!(marked(&s), vec!["QUICK"]); } #[test] - fn multiple_terms_both_highlighted() { - let s = render( + fn multiple_terms_both_marked() { + let s = extract( "the quick brown fox jumps over the lazy dog", &["quick", "lazy"], - &Options { - approx_chars: 200, - ..Options::default() - }, + &Options::default(), ); - assert!(s.contains("quick"), "got {s}"); - assert!(s.contains("lazy"), "got {s}"); + assert_eq!(marked(&s), vec!["quick", "lazy"]); + assert_ranges_valid(&s); } #[test] - fn window_trims_with_ellipsis_on_both_sides() { - let text = - "prefix ".repeat(20) + "MATCH in middle " + &"suffix ".repeat(20); - let s = render(&text, &["MATCH"], &opts_small()); - assert!(s.starts_with("…"), "got {s}"); - assert!(s.ends_with("…"), "got {s}"); - assert!(s.contains("MATCH"), "got {s}"); + fn window_truncation_flags_on_both_sides() { + let text = "prefix ".repeat(20) + "MATCH in middle " + &"suffix ".repeat(20); + let s = extract(&text, &["MATCH"], &opts_small()); + assert!(s.truncated_start); + assert!(s.truncated_end); + assert_eq!(marked(&s), vec!["MATCH"]); } #[test] - fn match_at_start_has_no_leading_ellipsis() { - let s = render("MATCH right at the start of this paragraph", &["match"], &opts_small()); - assert!(!s.starts_with("…"), "got {s}"); + fn match_at_start_not_truncated_left() { + let s = extract( + "MATCH right at the start of this paragraph", + &["match"], + &opts_small(), + ); + assert!(!s.truncated_start); + assert_eq!(s.ranges[0].0, 0); } #[test] - fn no_match_on_tail_returns_head() { + fn no_match_returns_head_without_ranges() { let text = "alpha beta gamma delta epsilon zeta eta theta iota kappa"; - let s = render(text, &["nomatch"], &opts_small()); - assert!(!s.contains("")); - assert!(s.starts_with("alpha")); + let s = extract(text, &["nomatch"], &opts_small()); + assert!(s.ranges.is_empty()); + assert!(s.window.starts_with("alpha")); } #[test] - fn overlapping_terms_do_not_nest_tags() { - // Two terms matching the same span must coalesce. - let s = render("the RUSTC compiler", &["rust", "rustc"], &opts_small()); - assert!(s.contains("RUSTC"), "got {s}"); - // No nested tags. - assert!(!s.contains(""), "got {s}"); + fn overlapping_terms_coalesce() { + let s = extract("the RUSTC compiler", &["rust", "rustc"], &opts_small()); + assert_eq!(marked(&s), vec!["RUSTC"]); + assert_ranges_valid(&s); } #[test] - fn utf8_boundary_safe_truncation() { - // Insert multi-byte chars near the window boundary. + fn utf8_boundaries_hold_with_multibyte_text() { let text = "café café café café café café café café café café"; - let s = render(text, &["nope"], &opts_small()); - // Returned string must be valid UTF-8 (push_str guarantees this only - // if we sliced on char boundaries). Assert by round-trip. - assert_eq!(s.as_str(), &s.clone()); + let s = extract(text, &["café"], &opts_small()); + assert!(!s.ranges.is_empty()); + assert_ranges_valid(&s); + for m in marked(&s) { + assert_eq!(m, "café"); + } } #[test] - fn match_near_right_edge_is_fully_shown() { + fn match_near_right_edge_is_fully_included() { let prefix = "x".repeat(30); let text = format!("{}{}", prefix, "LONGMATCHTERMTEXT"); - let s = render(&text, &["LONGMATCHTERMTEXT"], &opts_small()); - assert!(s.contains("LONGMATCHTERMTEXT"), "got {s}"); + let s = extract(&text, &["LONGMATCHTERMTEXT"], &opts_small()); + assert_eq!(marked(&s), vec!["LONGMATCHTERMTEXT"]); } #[test] fn empty_query_term_ignored() { - let s = render("hello world", &["", "world"], &opts_small()); - assert!(s.contains("world"), "got {s}"); + let s = extract("hello world", &["", "world"], &opts_small()); + assert_eq!(marked(&s), vec!["world"]); + } + + #[test] + fn window_around_basic() { + let text = "prefix ".repeat(20) + "NEEDLE" + &" suffix".repeat(20); + let at = text.find("NEEDLE").unwrap(); + let s = window_around(&text, (at, at + 6), &opts_small()); + assert_eq!(marked(&s), vec!["NEEDLE"]); + assert!(s.truncated_start && s.truncated_end); + assert_ranges_valid(&s); + } + + #[test] + fn window_around_clamps_out_of_bounds() { + let s = window_around("tiny", (2, 999), &opts_small()); + assert_eq!(s.window, "tiny"); + assert_eq!(s.ranges, vec![(2, 4)]); + // Fully out-of-range → no ranges, but never a panic. + let s = window_around("tiny", (999, 1000), &opts_small()); + assert!(s.ranges.is_empty()); + } + + #[test] + fn window_around_aligns_multibyte_boundaries() { + let text = "ééééééééé needle ééééééééé"; + // Deliberately mis-aligned offsets inside multi-byte sequences. + let s = window_around(text, (1, 3), &opts_small()); + assert_ranges_valid(&s); + } + + #[test] + fn count_occurrences_cases() { + assert_eq!(count_occurrences("aaaa", "aaa", true), 1, "non-overlapping"); + assert_eq!(count_occurrences("abcABC", "abc", true), 1); + assert_eq!(count_occurrences("abcABC", "abc", false), 2); + assert_eq!(count_occurrences("", "x", true), 0); + assert_eq!(count_occurrences("xyz", "", true), 0); + assert_eq!(count_occurrences("no hits here", "zzz", false), 0); + assert_eq!(count_occurrences("ab ab ab", "ab", true), 3); } } diff --git a/crates/quicksearch-core/src/walk.rs b/crates/quicksearch-core/src/walk.rs new file mode 100644 index 0000000..7cd431f --- /dev/null +++ b/crates/quicksearch-core/src/walk.rs @@ -0,0 +1,1026 @@ +//! Parallel filesystem walk for the full indexing run. +//! +//! One shared queue of directories, N worker threads. The important part is +//! *what* a worker keeps together: it reads a directory **and** does that +//! directory's per-file work — stat, classify, hash — before moving on. +//! +//! That grouping is the whole design. On SMB, one `QUERY_DIRECTORY` returns +//! size, mtime and attributes for every entry in a directory, and the cifs +//! client primes its inode cache from the reply — but only for `actimeo`, +//! one second by default, and end-user mount options are not ours to set. A +//! `stat` issued right after the directory read is therefore free, while the +//! same `stat` a few seconds later is a full network round trip. Parallelising +//! the directory reads alone — what a general-purpose parallel walker does — +//! hands entries to a consumer that stats them well outside that window, so it +//! throws the cache away and lands *slower* than a serial walk while burning +//! more CPU. +//! +//! The second property this buys: every path below a root is canonical by +//! construction. Roots are canonicalized once at seed time and directories are +//! only ever reached by joining names onto them, so per-file `canonicalize` +//! calls — roughly one `readlink` per path component, per file — disappear. +//! Symlinks are the one exception and are resolved where they are found. +//! +//! Deliberately std-only (`std::thread` + `std::sync::mpsc`), matching the +//! house style set out in [`crate::watcher`]. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Condvar, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::UNIX_EPOCH; + +use crate::config::{Config, IgnoreSet}; +use crate::extract::Registry; +use crate::file_handling::{ + classify_for_indexing, path_to_db_string, prepare_file_record, warn_if_unrepresentable, + ExistingFileEntry, + FileIndexAction, OwnedNewFile, UnreadableDirs, +}; +use crate::indexing::should_abort; + +/// Files one worker takes for itself before handing the rest to the pool. +/// +/// Without this split a single very wide directory — a Photos or Downloads +/// folder, a scanned-document share — would be walked by exactly one thread. +const FILES_PER_JOB: usize = 128; + +/// Bounded hand-off to the DB writer. Deep enough that workers don't stall +/// while a transaction commits, shallow enough to bound memory. +const CHANNEL_CAP: usize = 4096; + +/// Worker threads for a root on local storage. The work is latency-bound +/// rather than CPU-bound, but a local disk needs little queueing and deep +/// parallelism just adds seeks. +const LOCAL_THREADS: usize = 4; + +/// Worker threads for a root on a network filesystem. Every uncached +/// metadata operation is a round trip, so throughput is round-trips-in-flight +/// divided by latency; threads are how we raise the numerator. +const NETWORK_THREADS: usize = 16; + +/// One file the walk found, with everything the DB writer needs. +#[derive(Debug)] +pub struct WalkedFile { + /// Canonical path, and the `files.path` key. + /// + /// Present for *every* file the walk saw, including unchanged ones and + /// ones that could not be read. The caller's "seen" set drives stale-row + /// deletion, so a path missing from this stream is a path whose index row + /// gets deleted. + pub path: String, + pub action: FileIndexAction, + /// `None` when there is nothing to write: the file was unchanged, or its + /// record could not be built. Never a reason to drop [`WalkedFile::path`]. + pub record: Option, +} + +/// Work waiting for a thread. +enum Job { + /// Read this directory and process its files. + Dir(PathBuf), + /// Process this slice of one directory's files, split off because the + /// directory was too wide for one worker to be worth serialising on. + Files(Vec), +} + +#[derive(Default)] +struct Queue { + /// LIFO. A directory's children are processed close in time to the read + /// that discovered them, which is what the attribute cache rewards, and it + /// keeps the live frontier depth-first-ish instead of holding an entire + /// breadth-first level in memory. + jobs: Vec, + /// Workers currently holding a job — that is, workers that may still push + /// more. The walk is over when this is zero and `jobs` is empty. + active: usize, + /// Canonical directories already queued. Collapses overlapping roots and + /// makes symlink cycles impossible: a cycle must revisit a canonical path, + /// and every directory pushed here is canonical. + seen_dirs: HashSet, + done: bool, +} + +struct Shared { + queue: Mutex, + idle: Condvar, + /// Workers currently processing (not parked waiting for work). Purely + /// observational, for progress display: two relaxed atomic ops per + /// *job* (a directory read plus up to [`FILES_PER_JOB`] files), so it + /// costs nothing the queue mutex didn't already. + busy: AtomicUsize, +} + +/// Decrements the busy count however the worker leaves its job — including +/// early returns on stop and panics. +struct BusyGuard<'a>(&'a AtomicUsize); + +impl Drop for BusyGuard<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +/// Lock-free view of walker activity for progress displays. +#[derive(Clone)] +pub struct WorkerStats { + shared: Arc, + total: usize, +} + +impl WorkerStats { + /// Workers doing work right now (the rest are parked). + pub fn active(&self) -> usize { + self.shared.busy.load(Ordering::Relaxed).min(self.total) + } + + pub fn total(&self) -> usize { + self.total + } +} + +impl Shared { + /// Claim a job, blocking while other workers are still running. + /// + /// Returns `None` only when the queue is empty *and* no worker holds a + /// job — at that instant nobody is left who could push more, so the walk + /// is provably finished. + fn take(&self) -> Option<(Job, ActiveJob<'_>)> { + let mut q = self.queue.lock().unwrap(); + loop { + if q.done { + return None; + } + if let Some(job) = q.jobs.pop() { + q.active += 1; + return Some((job, ActiveJob { shared: self, finished: false })); + } + if q.active == 0 { + q.done = true; + self.idle.notify_all(); + return None; + } + q = self.idle.wait(q).unwrap(); + } + } + + /// Push discovered work and give the job slot back, under a single lock + /// acquisition. Doing both together is what makes the `active == 0` test + /// in [`Shared::take`] an end-of-walk proof rather than a race: a worker + /// that has popped the last job but not yet published its children must + /// never look idle. + fn publish(&self, found: Vec) { + let mut q = self.queue.lock().unwrap(); + for job in found { + if let Job::Dir(ref dir) = job { + if !q.seen_dirs.insert(dir.clone()) { + continue; + } + } + q.jobs.push(job); + } + q.active -= 1; + self.idle.notify_all(); + } + + fn shutdown(&self) { + let mut q = self.queue.lock().unwrap(); + q.done = true; + self.idle.notify_all(); + } +} + +/// Hands the job slot back even if the worker panics or returns early. A +/// stranded count would leave every other worker waiting on a number that +/// never reaches zero. +struct ActiveJob<'a> { + shared: &'a Shared, + finished: bool, +} + +impl ActiveJob<'_> { + fn finish(mut self, found: Vec) { + self.shared.publish(found); + self.finished = true; + } +} + +impl Drop for ActiveJob<'_> { + fn drop(&mut self) { + if !self.finished { + self.shared.publish(Vec::new()); + } + } +} + +struct Ctx { + follow_symlinks: bool, + include_hidden: bool, + ignore: IgnoreSet, + existing_files: Arc>, + config: Config, + /// Lets a worker finish small text files outright: the head it reads to + /// hash them is already their entire contents, so an extractor that works + /// from bytes saves the content pass an open/read/close per file. + registry: Arc, + unreadable: UnreadableDirs, + stop_flag: Arc>, + suspend_flag: Arc, +} + +/// Read one directory, apply the hidden/ignore rules, and split the result: +/// subdirectories and overflow file chunks go to `found` for the pool, the +/// remaining files come back for this worker to handle immediately. +fn read_directory(dir: &Path, ctx: &Ctx, found: &mut Vec) -> Vec { + let entries = match fs::read_dir(dir) { + Ok(entries) => entries, + Err(e) => { + // Not the same as "this directory is empty": see UnreadableDirs. + crate::log_warn!("cannot read {}: {}", dir.display(), e); + ctx.unreadable.record(dir.to_path_buf()); + return Vec::new(); + } + }; + + let mut files = Vec::new(); + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(e) => { + crate::log_warn!("cannot read an entry of {}: {}", dir.display(), e); + ctx.unreadable.record(dir.to_path_buf()); + continue; + } + }; + + let name = entry.file_name(); + let name = name.to_string_lossy(); + // `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()) + { + continue; + } + if ctx.ignore.matches_component(&name) { + continue; + } + let path = entry.path(); + if ctx.ignore.matches_path_pattern(&path) { + continue; + } + + // `file_type` is the cached `d_type` from the directory read, so + // splitting directories from files here is free. + match entry.file_type() { + Ok(ft) if ft.is_dir() => found.push(Job::Dir(path)), + Ok(ft) if ft.is_symlink() => { + // 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. + if let Ok(target) = path.canonicalize() { + match fs::metadata(&target) { + Ok(m) if m.is_dir() => { + if ctx.follow_symlinks { + found.push(Job::Dir(target)); + } + } + Ok(_) => files.push(target), + Err(_) => {} + } + } + } + Ok(_) => files.push(path), + Err(_) => {} + } + } + + // Spread a wide directory across the pool, keeping the tail for + // ourselves so the entries the read just warmed are handled now. + while files.len() > FILES_PER_JOB { + let chunk = files.split_off(files.len() - FILES_PER_JOB); + found.push(Job::Files(chunk)); + } + files +} + +/// One `stat`, then classify; only files that are actually going to be +/// written get opened, and small text files are finished outright. +fn prepare(path: PathBuf, ctx: &Ctx) -> WalkedFile { + let db_path = path_to_db_string(&path); + + // A name that is not valid UTF-8 cannot be stored in `files.path` and read + // back as the same file, so there is nothing to hash or text-index. `Skip` + // rather than an early return with no entry: the caller reads a missing + // path as "deleted", and this file was seen, not removed. + if warn_if_unrepresentable(&path) { + return WalkedFile { path: db_path, action: FileIndexAction::Skip, record: None }; + } + + let Ok(meta) = fs::metadata(&path) else { + // Seen but unreadable. Emitting it anyway keeps its index row alive: + // a transient stat failure must not read as "deleted". + return WalkedFile { path: db_path, action: FileIndexAction::Skip, record: None }; + }; + let Some(mtime) = meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + else { + return WalkedFile { path: db_path, action: FileIndexAction::Skip, record: None }; + }; + + let action = classify_for_indexing(&db_path, mtime, &ctx.existing_files); + let record = match action { + // Unchanged: never opened, never hashed. This is nearly every file on + // a re-index, and it is the case that has to stay at one syscall. + FileIndexAction::Skip => None, + // `prepare_file_record` gates on `is_file()`, which is what keeps us + // from opening a FIFO — that would block forever, uninterruptibly. + _ => prepare_file_record(&db_path, &meta, &ctx.config, &ctx.registry), + }; + + WalkedFile { path: db_path, action, record } +} + +fn worker(shared: &Shared, ctx: &Ctx, tx: &mpsc::SyncSender) { + while let Some((job, slot)) = shared.take() { + shared.busy.fetch_add(1, Ordering::Relaxed); + let _busy = BusyGuard(&shared.busy); + if should_abort(&ctx.stop_flag, &ctx.suspend_flag) { + shared.shutdown(); + return; + } + + let mut found = Vec::new(); + let files = match job { + Job::Dir(dir) => read_directory(&dir, ctx, &mut found), + Job::Files(files) => files, + }; + + // Hand the subdirectories over before doing our own per-file work, so + // the rest of the pool never idles waiting behind one worker. This + // also confines the job slot to `read_directory`. + slot.finish(found); + + for path in files { + if should_abort(&ctx.stop_flag, &ctx.suspend_flag) { + shared.shutdown(); + return; + } + if tx.send(prepare(path, ctx)).is_err() { + // Receiver gone: the run was stopped or failed. Not an error. + shared.shutdown(); + return; + } + } + } +} + +/// A running parallel walk. Iterating it drains finished files; dropping it +/// stops the workers and joins them. +pub struct ParallelWalk { + rx: Option>, + handles: Vec>, + shared: Arc, + ctx: Arc, +} + +impl ParallelWalk { + /// Directories that could not be read. Only final once the iterator has + /// ended, because the channel closes when the last worker exits. + pub fn unreadable(&self) -> &UnreadableDirs { + &self.ctx.unreadable + } + + /// A cheap, cloneable handle for reading worker activity while the + /// walk's iterator is mutably borrowed by a `for` loop. + pub fn worker_stats(&self) -> WorkerStats { + WorkerStats { + shared: self.shared.clone(), + total: self.handles.len(), + } + } + + /// Join the workers and report whether every one of them finished + /// cleanly. + /// + /// The caller needs this because a dead worker and a finished worker look + /// identical from the receiving end: both close the channel, so iteration + /// simply ends. Treating a panicked walk as a completed one would hand + /// stale cleanup a partial file set and delete everything the dead workers + /// never reached. + pub fn finish(&mut self) -> bool { + // Dropping the receiver first releases any worker parked in `send`. + self.rx = None; + let mut clean = true; + for handle in self.handles.drain(..) { + if handle.join().is_err() { + clean = false; + } + } + clean + } +} + +/// Result of a non-blocking pull from a walk. +pub enum TryNext { + Item(WalkedFile), + /// Nothing ready right now; the walk is still running. + Empty, + /// The walk has ended (all workers exited, for any reason). + Finished, +} + +impl ParallelWalk { + /// Non-blocking variant of `next`, for callers multiplexing several + /// walks (the per-root writer loop). + pub fn try_next(&mut self) -> TryNext { + match &self.rx { + None => TryNext::Finished, + Some(rx) => match rx.try_recv() { + Ok(file) => TryNext::Item(file), + Err(mpsc::TryRecvError::Empty) => TryNext::Empty, + Err(mpsc::TryRecvError::Disconnected) => TryNext::Finished, + }, + } + } +} + +impl Iterator for ParallelWalk { + type Item = WalkedFile; + + fn next(&mut self) -> Option { + self.rx.as_ref()?.recv().ok() + } +} + +impl Drop for ParallelWalk { + fn drop(&mut self) { + self.shared.shutdown(); + // No-op if the caller already called `finish`. + self.finish(); + } +} + +/// Walk `roots` in parallel, yielding every indexable file exactly once per +/// canonical path. +/// +/// `workers` is explicit so callers can honour per-root overrides; use +/// [`thread_count_for`] for the storage-appropriate default. Clamped to +/// 1..=64. +#[allow(clippy::too_many_arguments)] +pub fn walk_indexable_files( + roots: &[String], + follow_symlinks: bool, + include_hidden: bool, + ignore: IgnoreSet, + existing_files: Arc>, + config: Config, + registry: Arc, + stop_flag: Arc>, + suspend_flag: Arc, + workers: usize, +) -> ParallelWalk { + let mut queue = Queue::default(); + let mut unresolvable: Vec = Vec::new(); + for root in roots { + // Canonicalize here, not just at the caller, so "everything below a + // root is already canonical" holds however this is called. Without it + // a non-canonical root would spell every path below it differently + // from the stored rows: every file would look new *and* every stored + // row would look stale. + // + // Roots themselves are never filtered — the user chose them, so a + // hidden or ignore-matching root still gets walked. + match fs::canonicalize(root) { + Ok(dir) => { + let dir = PathBuf::from(path_to_db_string(&dir)); + if queue.seen_dirs.insert(dir.clone()) { + queue.jobs.push(Job::Dir(dir)); + } + } + Err(e) => { + crate::log_warn!("cannot resolve indexing root {}: {}", root, e); + // An unmounted or renamed root yields nothing, which is + // indistinguishable from "all its files were deleted" unless + // we say so. Recorded here so stale cleanup leaves it alone. + unresolvable.push(PathBuf::from(root)); + } + } + } + + let threads = workers.clamp(1, 64); + let shared = Arc::new(Shared { + queue: Mutex::new(queue), + idle: Condvar::new(), + busy: AtomicUsize::new(0), + }); + let ctx = Arc::new(Ctx { + follow_symlinks, + include_hidden, + ignore, + existing_files, + config, + registry, + unreadable: UnreadableDirs::default(), + stop_flag, + suspend_flag, + }); + + for root in unresolvable { + ctx.unreadable.record(root); + } + + let (tx, rx) = mpsc::sync_channel(CHANNEL_CAP); + let handles = (0..threads) + .map(|_| { + let (shared, ctx, tx) = (shared.clone(), ctx.clone(), tx.clone()); + thread::spawn(move || worker(&shared, &ctx, &tx)) + }) + .collect(); + // The workers must hold the only senders, or `recv` never reports the end + // of the walk and phase 1 hangs forever. + drop(tx); + + ParallelWalk { rx: Some(rx), handles, shared, ctx } +} + +/// Pick a worker count for these roots. +/// +/// A network share wants far more threads than cores, because each worker +/// spends its time blocked on a round trip rather than on the CPU; a local +/// disk wants few. Users cannot be asked to tune this — the indexer runs on +/// machines we do not configure — so it is detected rather than configured. +/// With a mix of roots the higher count wins: over-threading a local disk +/// costs a little, under-threading a share costs everything. +pub fn thread_count_for(roots: &[String]) -> usize { + let network = roots + .iter() + .any(|r| crate::platform::is_network_path(Path::new(r))); + if network { + NETWORK_THREADS + } else { + LOCAL_THREADS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_tree(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "quicksearch-pwalk-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&p).unwrap(); + p + } + + fn touch(p: &Path) { + fs::create_dir_all(p.parent().unwrap()).unwrap(); + fs::write(p, b"x").unwrap(); + } + + fn walk(root: &Path, existing: HashMap) -> Vec { + walk_with(root, existing, false, false) + } + + fn walk_with( + root: &Path, + existing: HashMap, + follow_symlinks: bool, + include_hidden: bool, + ) -> Vec { + walk_indexable_files( + &[root.to_string_lossy().into_owned()], + follow_symlinks, + include_hidden, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(existing), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ) + .collect() + } + + 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()) + .collect(); + n.sort(); + n + } + + #[test] + fn walks_a_nested_tree_exactly_once() { + let root = tmp_tree("nested"); + touch(&root.join("a.txt")); + touch(&root.join("sub/b.txt")); + touch(&root.join("sub/deep/c.txt")); + touch(&root.join("other/d.txt")); + + let files = walk(&root, HashMap::new()); + assert_eq!(names(&files), vec!["a.txt", "b.txt", "c.txt", "d.txt"]); + + let unique: HashSet<&String> = files.iter().map(|f| &f.path).collect(); + assert_eq!(unique.len(), files.len(), "no path may be yielded twice"); + fs::remove_dir_all(&root).ok(); + } + + /// A name that is not valid UTF-8 survives `stat` but not the round trip + /// through `files.path`, so it must be skipped before anything tries to + /// open it by that string. Unix only: on Windows `OsString` comes from + /// UTF-16 and there is no way to build the case. + #[cfg(unix)] + #[test] + fn a_non_utf8_name_is_skipped_and_never_prepared() { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + let root = tmp_tree("nonutf8"); + touch(&root.join("plain.txt")); + // 0xFF is not valid UTF-8 anywhere in a sequence, so the name only + // survives `to_string_lossy` as U+FFFD. + let bad = root.join(OsStr::from_bytes(b"DRH257\xff~X.MP4")); + touch(&bad); + assert!(bad.symlink_metadata().is_ok(), "the file really is on disk"); + + let files = walk(&root, HashMap::new()); + + // Both are yielded, so neither reads as deleted... + assert_eq!(files.len(), 2, "the bad name is still reported as seen"); + // ...but only the representable one is prepared for insertion, which + // is what keeps it out of the hasher and out of FTS. + let prepared: Vec<&WalkedFile> = files.iter().filter(|f| f.record.is_some()).collect(); + assert_eq!(prepared.len(), 1); + assert!(prepared[0].path.ends_with("plain.txt")); + + 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"); + + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn wide_directory_is_split_across_workers_without_loss() { + // More than FILES_PER_JOB in one flat directory, so the chunking path + // and the termination protocol both run under real contention. + let root = tmp_tree("wide"); + let count = FILES_PER_JOB * 4 + 7; + for i in 0..count { + touch(&root.join(format!("f{:05}.txt", i))); + } + + let files = walk(&root, HashMap::new()); + assert_eq!(files.len(), count, "every file is yielded exactly once"); + let unique: HashSet<&String> = files.iter().map(|f| &f.path).collect(); + assert_eq!(unique.len(), count, "and none is yielded twice"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn terminates_on_an_empty_root() { + // The "queue empty at t=0" corner: every worker must observe the walk + // as finished rather than waiting for work that will never arrive. + let root = tmp_tree("empty"); + assert!(walk(&root, HashMap::new()).is_empty()); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn unchanged_files_are_never_opened() { + // The property the whole SMB story rests on: a re-index of an + // unchanged tree must cost one stat per file and no file opens. + let root = tmp_tree("skip"); + touch(&root.join("a.txt")); + touch(&root.join("sub/b.txt")); + + let first = walk(&root, HashMap::new()); + assert_eq!(first.len(), 2); + assert!(first.iter().all(|f| f.action == FileIndexAction::Insert)); + + let existing: HashMap = first + .iter() + .map(|f| { + (f.path.clone(), ExistingFileEntry { mtime: f.record.as_ref().unwrap().mtime }) + }) + .collect(); + + let second = walk(&root, existing); + 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"); + } + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn every_seen_file_is_reported_even_when_it_cannot_be_read() { + // A path missing from the stream gets its index row deleted, so + // "couldn't process it" must still be reported as seen. + let root = tmp_tree("unreadable-file"); + touch(&root.join("fine.txt")); + let bad = root.join("bad.txt"); + touch(&bad); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&bad, fs::Permissions::from_mode(0o000)).unwrap(); + + let files = walk(&root, HashMap::new()); + fs::set_permissions(&bad, fs::Permissions::from_mode(0o644)).ok(); + + assert_eq!(names(&files), vec!["bad.txt", "fine.txt"]); + let bad_entry = files.iter().find(|f| f.path.ends_with("bad.txt")).unwrap(); + assert!(bad_entry.record.is_none(), "unopenable, so no record"); + } + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn unreadable_directory_is_recorded_not_silently_empty() { + let root = tmp_tree("unreadable-dir"); + touch(&root.join("visible.txt")); + let locked = root.join("locked"); + touch(&locked.join("inside.txt")); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + + let mut w = walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(HashMap::new()), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ); + let files: Vec = w.by_ref().collect(); + let recorded = !w.unreadable().is_empty(); + let covers = w.unreadable().covers(locked.join("inside.txt").to_str().unwrap()); + + fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).ok(); + + assert_eq!(names(&files), vec!["visible.txt"]); + assert!(recorded, "the failure must be recorded"); + assert!(covers, "so rows beneath it survive stale cleanup"); + } + fs::remove_dir_all(&root).ok(); + } + + #[test] + #[cfg(unix)] + fn symlink_loop_terminates() { + // A hand-rolled walker has none of walkdir's cycle detection; the + // canonical-directory set is what stands in for it. + let root = tmp_tree("loop"); + touch(&root.join("real.txt")); + std::os::unix::fs::symlink(&root, root.join("self_link")).unwrap(); + + let files = walk_with(&root, HashMap::new(), true, false); + assert_eq!(names(&files), vec!["real.txt"], "the cycle is visited once"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + #[cfg(unix)] + fn symlinked_file_resolves_to_its_target_path() { + // Preserves the stored spelling: resolving links where they are found + // is what lets the per-file `canonicalize` go away without re-spelling + // rows on the next run. + // + // The walk reaches this file twice — directly, and through the alias — + // and reports it twice. That is deliberate: the walker dedupes + // *directories*, while the caller's `seen_paths` dedupes files, which + // is where a UNIQUE(path) violation would otherwise come from. What + // matters here is that both routes agree on the canonical path, so + // that dedup can work at all. + 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(); + + let files = walk(&root, HashMap::new()); + 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"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn hidden_and_ignored_entries_are_pruned() { + let root = tmp_tree("prune"); + touch(&root.join("keep.txt")); + touch(&root.join("sub/keep2.txt")); + touch(&root.join("sub/skip.tmp")); + touch(&root.join(".hidden/inside.txt")); + 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 files: Vec = walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + ignore, + Arc::new(HashMap::new()), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ) + .collect(); + assert_eq!(names(&files), vec!["keep.txt", "keep2.txt"]); + + let files = walk_with(&root, HashMap::new(), false, true); + assert_eq!( + names(&files), + 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(); + } + + #[test] + fn hidden_root_is_still_walked() { + // Roots are chosen explicitly, so the hidden rule must not silence one. + let base = tmp_tree("hidden-root"); + let root = base.join(".config"); + touch(&root.join("app.conf")); + + let files = walk(&root, HashMap::new()); + assert_eq!(names(&files), vec!["app.conf"]); + fs::remove_dir_all(&base).ok(); + } + + #[test] + fn stop_flag_ends_the_walk_without_hanging() { + let root = tmp_tree("stop"); + for i in 0..500 { + touch(&root.join(format!("f{:04}.txt", i))); + } + + let stop = Arc::new(Mutex::new(true)); + let files: Vec = walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(HashMap::new()), + Config::default(), + Arc::new(Registry::default_set()), + stop, + Arc::new(AtomicBool::new(false)), + 4, + ) + .collect(); + + assert!(files.len() < 500, "an already-stopped walk does not run to completion"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn dropping_the_walk_early_does_not_hang() { + // Workers blocked in `send` must be released by the receiver going + // away, or `Drop` would join threads that never wake. + let root = tmp_tree("early-drop"); + for i in 0..2000 { + touch(&root.join(format!("sub{}/f{}.txt", i % 10, i))); + } + + let mut w = walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(HashMap::new()), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ); + assert!(w.next().is_some()); + drop(w); // must return, not deadlock + + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn overlapping_roots_yield_each_file_once() { + let root = tmp_tree("overlap"); + touch(&root.join("sub/a.txt")); + + let files: Vec = walk_indexable_files( + &[ + root.to_string_lossy().into_owned(), + root.join("sub").to_string_lossy().into_owned(), + ], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(HashMap::new()), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ) + .collect(); + + assert_eq!(files.len(), 1, "the nested root must not double-index"); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn repeated_walks_agree_on_the_result_set() { + // The termination protocol is racy by nature; run it enough times + // under contention that a premature exit would show up. + let root = tmp_tree("repeat"); + for i in 0..40 { + touch(&root.join(format!("d{}/f{}.txt", i % 7, i))); + } + let expected = names(&walk(&root, HashMap::new())); + assert_eq!(expected.len(), 40); + + for run in 0..30 { + assert_eq!(names(&walk(&root, HashMap::new())), expected, "run {}", run); + } + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn finish_reports_a_clean_walk_and_is_idempotent() { + // The caller gates stale-row deletion on this: a walk whose workers + // died yields a partial file set, and "not seen" would otherwise be + // read as "deleted". + let root = tmp_tree("finish"); + touch(&root.join("a.txt")); + touch(&root.join("sub/b.txt")); + + let mut w = walk_indexable_files( + &[root.to_string_lossy().into_owned()], + false, + false, + IgnoreSet::compile(&[]).unwrap(), + Arc::new(HashMap::new()), + Config::default(), + Arc::new(Registry::default_set()), + Arc::new(Mutex::new(false)), + Arc::new(AtomicBool::new(false)), + 4, + ); + let files: Vec = w.by_ref().collect(); + assert_eq!(files.len(), 2); + assert!(w.finish(), "no worker panicked"); + // Drop calls it again; joining an already-drained handle list must be + // a no-op rather than a panic. + assert!(w.finish()); + drop(w); + + fs::remove_dir_all(&root).ok(); + } + + #[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); + fs::remove_dir_all(&root).ok(); + } +} diff --git a/crates/quicksearch-core/src/watcher.rs b/crates/quicksearch-core/src/watcher.rs index 16abd08..a0300ef 100644 --- a/crates/quicksearch-core/src/watcher.rs +++ b/crates/quicksearch-core/src/watcher.rs @@ -7,23 +7,78 @@ //! emitted [`FsEvent`]s — typically to the QuickSearch database via the //! [`crate::db::repo`] helpers. //! -//! Inotify watch-limit (ENOSPC) handling: the watcher logs a prominent -//! warning on first occurrence and switches the offending root to periodic -//! rescans. Rescan cadence is configurable in [`WatcherConfig`]. +//! # Two registration strategies +//! +//! Which one applies is [`crate::platform::WATCH_ROOTS_RECURSIVELY`]. +//! +//! **Per directory (inotify).** inotify has no recursive watch: one watch +//! descriptor covers exactly one directory's entries. +//! `RecursiveMode::Recursive` is emulated inside `notify` by walking the tree +//! and adding one watch per directory — with no way to skip subtrees. That +//! spent descriptors on `.git`, `node_modules`, and hidden directories whose +//! events the indexer then discarded, and exhausted +//! `fs.inotify.max_user_watches` on large roots. So this module walks the +//! roots itself through [`crate::file_handling::filtered_dirs`] — the same +//! pruning the indexer uses — and registers each surviving directory +//! `NonRecursive`. Taking over recursion means also registering directories +//! created later, which [`register_tree`] does from the event loop. +//! +//! **Per root (`ReadDirectoryChangesW`).** One handle covers the whole +//! subtree, including directories created later, so the walk and the +//! per-directory bookkeeping are skipped entirely. Registering per directory +//! here would be actively harmful rather than merely wasteful: `notify` +//! allocates a 16 KiB buffer inline per watch plus a directory handle, so a +//! large tree would ask for gigabytes of buffers and tens of thousands of +//! handles. The saving that pruning bought on inotify is unavailable — the +//! events arrive whether or not we want them — so the same filters run on the +//! event path instead, in [`is_event_interesting`]. +//! +//! # The cap +//! +//! Registration stops at [`WatcherConfig::max_watched_dirs`] and reports +//! [`WatchError::TooManyDirectories`]; a kernel refusal reports +//! [`WatchError::KernelLimit`]. Both are all-or-nothing — the whole +//! watcher fails and no root gets live updates, leaving the coordinator's +//! periodic reindex as the only refresh path. Partial registration is +//! worse than none, because a half-watched root looks live while going +//! silently stale. +//! +//! Only those two are fatal. A single directory the kernel refuses for its +//! own reasons — an unreadable folder, one deleted mid-walk — is logged and +//! skipped: it costs its own events, not the tree's, and letting it abort +//! registration would report that incidental failure as the reason live +//! updates are off. See [`add_watch`]. +//! +//! Neither limit is reachable under the per-root strategy, where the watch +//! count is the number of configured roots. //! //! This module deliberately stays sync (std::thread + crossbeam-style //! channels via `std::sync::mpsc`) so it integrates cleanly with the //! existing indexer which is not async. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; -use notify::{Config as NotifyConfig, 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}; +use crate::platform::path_has_hidden_component_under; + +/// Directory budget for live updates. Past this, watching costs more than +/// it returns: the kernel's per-user `max_user_watches` is a shared +/// resource, and a tree this size is cheaper to rescan on a timer than to +/// track. Roughly 1 KiB of unswappable kernel memory per watch. +/// +/// Applies only where watches are taken per directory. Under a per-root +/// recursive watch the count is the number of configured roots, so the cap is +/// unreachable by design rather than by generosity. +pub const DEFAULT_MAX_WATCHED_DIRS: usize = 128_000; /// An event surfaced to the caller after debouncing. #[derive(Debug, Clone, PartialEq, Eq)] @@ -41,6 +96,67 @@ pub enum FsEvent { /// work short and push heavier operations to their own worker. pub type EventSink = Arc; +/// Which directories are worth a watch descriptor. Mirrors the indexer's +/// walk filters so the watcher never spends a descriptor on a subtree the +/// indexer would discard. +#[derive(Debug, Clone)] +pub struct WatchFilters { + pub include_hidden: bool, + pub follow_symlinks: bool, + pub ignore: Arc, +} + +/// Why live updates are unavailable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WatchError { + /// The indexed roots hold more directories than the cap allows. + 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 }, + Other(String), +} + +impl std::fmt::Display for WatchError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WatchError::TooManyDirectories { dirs, cap } => write!( + f, + "the total number of directories to monitor exceeds {} \ + (stopped counting at {}); live updates are disabled and \ + changes are picked up by the periodic reindex instead", + fmt_cap(*cap), + dirs + ), + WatchError::KernelLimit { registered } => write!( + f, + "the system watch limit was reached after {} directories{}", + registered, + // Only inotify has a tunable the user can actually raise; + // pointing a Windows user at a sysctl would be nonsense. + if cfg!(target_os = "linux") { + " (raise fs.inotify.max_user_watches to watch more)" + } else { + "" + } + ), + WatchError::Other(msg) => write!(f, "{}", msg), + } + } +} + +impl std::error::Error for WatchError {} + +/// Render the directory cap compactly: the default 128_000 reads "128k". +fn fmt_cap(cap: usize) -> String { + if cap >= 1000 && cap % 1000 == 0 { + format!("{}k", cap / 1000) + } else { + cap.to_string() + } +} + #[derive(Debug, Clone)] pub struct WatcherConfig { /// Per-directory debounce window. Bursts of events in the same directory @@ -54,6 +170,8 @@ pub struct WatcherConfig { pub max_dirs_per_tick: usize, /// When to garbage-collect stale throttle entries (idle > window * N). pub prune_max_age_multiplier: u32, + /// Directory budget; see [`DEFAULT_MAX_WATCHED_DIRS`]. + pub max_watched_dirs: usize, } impl Default for WatcherConfig { @@ -63,6 +181,101 @@ impl Default for WatcherConfig { tick_interval: Duration::from_millis(500), max_dirs_per_tick: 64, prune_max_age_multiplier: 10, + max_watched_dirs: DEFAULT_MAX_WATCHED_DIRS, + } + } +} + +/// The set of registered watches, plus the notify handle that owns them. +/// +/// Held behind a mutex because both the registering walk and the event loop +/// add to it. The poll surface ([`Watcher::watched_dirs`], +/// [`Watcher::is_degraded`]) reads atomics instead, so a coordinator poll +/// never blocks behind a large subtree registration. +struct WatchRegistry { + raw: RecommendedWatcher, + dirs: HashSet, + cap: usize, +} + +/// The mode every `watch()` call uses on this platform. See +/// [`crate::platform::WATCH_ROOTS_RECURSIVELY`] for why it differs. +const WATCH_MODE: RecursiveMode = if crate::platform::WATCH_ROOTS_RECURSIVELY { + RecursiveMode::Recursive +} else { + RecursiveMode::NonRecursive +}; + +impl WatchRegistry { + /// Register `dir` unless already watched. `Ok(false)` means "nothing to + /// do" — already watched, or the path vanished mid-walk. + fn add(&mut self, dir: &Path) -> Result { + if self.dirs.contains(dir) { + return Ok(false); + } + if self.dirs.len() >= self.cap { + return Err(WatchError::TooManyDirectories { + dirs: self.dirs.len(), + cap: self.cap, + }); + } + match self.raw.watch(dir, WATCH_MODE) { + Ok(()) => { + self.dirs.insert(dir.to_path_buf()); + Ok(true) + } + // notify maps ENOSPC from inotify_add_watch to this. + Err(e) if matches!(e.kind, NotifyErrorKind::MaxFilesWatch) => { + Err(WatchError::KernelLimit { + registered: self.dirs.len(), + }) + } + // Deleted between the walk and the watch call; not an error. + Err(e) if matches!(e.kind, NotifyErrorKind::PathNotFound) => Ok(false), + Err(e) => Err(WatchError::Other(format!("watch {}: {}", dir.display(), e))), + } + } + + /// Forget `dir` and every watched directory beneath it, returning how + /// many were dropped. + /// + /// `Path::starts_with` compares whole components, so `/a/bc` is not + /// treated as living under `/a/b`. The kernel drops watches for deleted + /// directories on its own; unwatching anyway keeps notify's internal + /// descriptor map from growing across a long session of directory churn. + fn remove_tree(&mut self, dir: &Path) -> usize { + let doomed: Vec = self + .dirs + .iter() + .filter(|d| d.starts_with(dir)) + .cloned() + .collect(); + for d in &doomed { + self.dirs.remove(d); + let _ = self.raw.unwatch(d); + } + doomed.len() + } +} + +/// Register `dir` during startup, propagating only the budget limits. +/// +/// A directory the kernel refuses on its own terms — most often one the user +/// cannot read, which the walk still hands us because its *parent* was +/// readable — costs live updates that one directory, not the tree. Treating +/// it as fatal used to abort the whole registration and report "permission +/// denied" as the reason live updates were off, hiding the real, actionable +/// limit from anyone whose roots also exceeded the directory budget. The +/// budget errors stay fatal: those really do mean the tree can't be covered. +fn add_watch(reg: &mut WatchRegistry, dir: &Path) -> Result<(), WatchError> { + match reg.add(dir) { + Ok(_) => Ok(()), + Err(limit @ (WatchError::TooManyDirectories { .. } | WatchError::KernelLimit { .. })) => { + Err(limit) + } + Err(e) => { + crate::log_warn!("watcher: {}", e); + Ok(()) } } } @@ -71,77 +284,144 @@ impl Default for WatcherConfig { pub struct Watcher { stop_flag: Arc, handle: Option>, - /// Held so notify::Watcher drops on stop (releasing inotify watches). - _raw: RecommendedWatcher, + dir_count: Arc, + degraded: Degraded, + /// Held so the notify handle drops on stop, releasing every watch. + _registry: Arc>, } +/// Set when the watcher runs out of budget *after* starting, carrying which +/// limit was hit so the UI can say the right thing. A plain flag would force +/// the coordinator to guess. +type Degraded = Arc>>; + impl Watcher { - /// Start watching `roots` recursively. Returns once the watcher is - /// registered and its background thread is running. + /// Register watches for every indexable directory under `roots` and + /// start the debouncing loop. + /// + /// Registration walks each root, so this takes proportional time on + /// large trees — callers run it off their main loop. pub fn start( roots: I, + filters: WatchFilters, config: WatcherConfig, sink: EventSink, - ) -> Result + ) -> Result where I: IntoIterator, P: AsRef, { let (tx, rx) = mpsc::channel::(); - let tx_for_cb = tx.clone(); - let mut watcher = RecommendedWatcher::new( + let degraded: Degraded = Arc::new(Mutex::new(None)); + let degraded_cb = degraded.clone(); + let raw = RecommendedWatcher::new( move |res: notify::Result| match res { Ok(ev) => { // A closed receiver just means the watcher was stopped; ignore. - let _ = tx_for_cb.send(ev); + let _ = tx.send(ev); } Err(e) => { - eprintln!("watcher: notify error: {}", e); + // The limit can also be hit asynchronously, when notify + // reacts to a directory appearing. + if matches!(e.kind, NotifyErrorKind::MaxFilesWatch) { + let mut slot = degraded_cb.lock().unwrap(); + slot.get_or_insert(WatchError::KernelLimit { registered: 0 }); + } + crate::log_warn!("watcher: notify error: {}", e); } }, NotifyConfig::default(), ) - .map_err(|e| format!("create watcher: {}", e))?; + .map_err(|e| WatchError::Other(format!("create watcher: {}", e)))?; - let mut watched_any = false; - for root in roots { - let root = root.as_ref(); - match watcher.watch(root, RecursiveMode::Recursive) { - Ok(_) => { - watched_any = true; + let registry = Arc::new(Mutex::new(WatchRegistry { + raw, + dirs: HashSet::new(), + cap: config.max_watched_dirs, + })); + + // Any error here drops `registry`, which drops the notify handle and + // releases every watch already taken — the all-or-nothing guarantee. + // Release is asynchronous: notify's Drop signals its event-loop + // thread, which then closes the inotify fd. Measured at ~50 ms for a + // few hundred watches, so a caller that immediately retries may + // briefly see the old descriptors still charged to the user's quota. + let roots: Vec = roots + .into_iter() + .map(|r| r.as_ref().to_path_buf()) + .collect(); + + { + let failures = UnreadableDirs::default(); + let mut reg = registry.lock().unwrap(); + for root in &roots { + if crate::platform::WATCH_ROOTS_RECURSIVELY { + // One watch covers the subtree. Ignored and hidden + // subtrees can't be skipped here — nothing is registered + // for them to skip — so their events are dropped on + // arrival instead, in `is_event_interesting`. + add_watch(&mut reg, root)?; + continue; } - Err(e) => { - // Best-effort: log and continue. ENOSPC (watch limit) is - // detected here by inspecting the error string — the - // notify crate doesn't expose a typed variant for it. - let msg = format!("{}", e); - if is_enospc_error(&msg) { - eprintln!( - "watcher: inotify watch limit exceeded for {}. \ - Increase fs.inotify.max_user_watches (currently the kernel default).", - root.display() - ); - } else { - eprintln!("watcher: watch({}): {}", root.display(), e); - } + let Some(root_str) = root.to_str() else { + crate::log_warn!("watcher: skipping non-UTF-8 root {}", root.display()); + continue; + }; + for entry in filtered_dirs( + root_str, + filters.follow_symlinks, + filters.include_hidden, + &filters.ignore, + &failures, + ) { + add_watch(&mut reg, entry.path())?; } } - } - if !watched_any { - return Err("watcher: no roots could be watched".into()); + if reg.dirs.is_empty() { + return Err(WatchError::Other("no roots could be watched".into())); + } } + let dir_count = Arc::new(AtomicUsize::new(registry.lock().unwrap().dirs.len())); let stop_flag = Arc::new(AtomicBool::new(false)); - let stop_clone = stop_flag.clone(); - let handle = thread::spawn(move || run_loop(rx, sink, config, stop_clone)); + let ctx = LoopCtx { + sink, + config, + stop: stop_flag.clone(), + registry: registry.clone(), + filters, + roots, + dir_count: dir_count.clone(), + degraded: degraded.clone(), + }; + let handle = thread::spawn(move || run_loop(rx, ctx)); Ok(Self { stop_flag, handle: Some(handle), - _raw: watcher, + dir_count, + degraded, + _registry: registry, }) } + /// How many directories currently hold a watch descriptor. + pub fn watched_dirs(&self) -> usize { + self.dir_count.load(Ordering::Relaxed) + } + + /// Which limit the watcher hit after starting, if any. `Some` means it + /// can no longer see the whole tree; the coordinator polls this and + /// falls back to periodic rescans. + pub fn degraded_reason(&self) -> Option { + self.degraded.lock().unwrap().clone() + } + + /// Whether the watcher ran out of budget after starting. + pub fn is_degraded(&self) -> bool { + self.degraded.lock().unwrap().is_some() + } + /// Signal the background thread to stop and wait for it to join. Safe to /// call multiple times. pub fn stop(&mut self) { @@ -158,13 +438,30 @@ impl Drop for Watcher { } } -fn is_enospc_error(msg: &str) -> bool { - // notify crate wraps libc errors; the message includes "No space left" - // or the errno. Be generous in matching. - msg.contains("ENOSPC") - || msg.contains("No space left") - || msg.contains("inotify") - || msg.contains("watch limit") +/// Hand-written because `RecommendedWatcher` is not `Debug`, and the watch +/// set behind a mutex is not worth locking to print. +impl std::fmt::Debug for Watcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Watcher") + .field("watched_dirs", &self.watched_dirs()) + .field("degraded", &self.is_degraded()) + .finish() + } +} + +/// Everything the event loop needs, bundled to keep signatures readable. +struct LoopCtx { + sink: EventSink, + config: WatcherConfig, + stop: Arc, + registry: Arc>, + filters: WatchFilters, + /// The configured roots, needed to judge "hidden" relative to them: a + /// root may itself sit under a hidden directory (`~/.config/app`, or + /// anything below `%LOCALAPPDATA%`), and the walk keeps such a root. + roots: Vec, + dir_count: Arc, + degraded: Degraded, } /// A queued event, deduplicated per path within a window. @@ -193,12 +490,7 @@ struct DirThrottleEntry { immediate: bool, } -fn run_loop( - rx: mpsc::Receiver, - sink: EventSink, - config: WatcherConfig, - stop: Arc, -) { +fn run_loop(rx: mpsc::Receiver, ctx: LoopCtx) { let mut throttle: HashMap = HashMap::new(); // Pending rename halves keyed by cookie are not supported by notify 6.x's // high-level API uniformly across backends; when From/To aren't bundled @@ -207,14 +499,14 @@ fn run_loop( let mut tick_counter: u32 = 0; loop { - if stop.load(Ordering::Relaxed) { + if ctx.stop.load(Ordering::Relaxed) { break; } // Drain incoming events. Block briefly to avoid spinning when idle. - let deadline = Instant::now() + config.tick_interval; + let deadline = Instant::now() + ctx.config.tick_interval; loop { - if stop.load(Ordering::Relaxed) { + if ctx.stop.load(Ordering::Relaxed) { break; } let remaining = deadline.saturating_duration_since(Instant::now()); @@ -222,40 +514,136 @@ fn run_loop( break; } match rx.recv_timeout(remaining) { - Ok(ev) => handle_notify_event(&ev, &mut throttle, &sink), + Ok(ev) => handle_notify_event(&ev, &mut throttle, &ctx), Err(mpsc::RecvTimeoutError::Timeout) => break, Err(mpsc::RecvTimeoutError::Disconnected) => return, } } - if stop.load(Ordering::Relaxed) { + if ctx.stop.load(Ordering::Relaxed) { break; } // Tick: flush ready directories, up to max_dirs_per_tick. - flush_ready(&mut throttle, &sink, &config); + flush_ready(&mut throttle, &ctx.sink, &ctx.config); // Periodic GC of abandoned throttle entries. tick_counter = tick_counter.wrapping_add(1); if tick_counter % prune_interval_ticks == 0 { - let max_age = config + let max_age = ctx + .config .throttle_window - .saturating_mul(config.prune_max_age_multiplier); + .saturating_mul(ctx.config.prune_max_age_multiplier); prune_stale(&mut throttle, max_age); } } } +/// Register a directory that appeared after startup, and everything under +/// it — a whole tree can be moved in with a single event. +fn register_tree(ctx: &LoopCtx, root: &Path) { + let Some(root_str) = root.to_str() else { + return; + }; + let failures = UnreadableDirs::default(); + let mut reg = ctx.registry.lock().unwrap(); + for entry in filtered_dirs( + root_str, + ctx.filters.follow_symlinks, + ctx.filters.include_hidden, + &ctx.filters.ignore, + &failures, + ) { + // Same policy as startup: only the budget limits are fatal. + if let Err(limit) = add_watch(&mut reg, entry.path()) { + // Live updates can no longer cover the tree. Stop here and let + // the coordinator tear us down; a partially watched tree would + // look live while going silently stale. Keep the first reason — + // later ones are consequences of the same exhaustion. + let mut slot = ctx.degraded.lock().unwrap(); + slot.get_or_insert(limit); + break; + } + } + ctx.dir_count.store(reg.dirs.len(), Ordering::Relaxed); +} + +/// Register `path` if it is a directory the indexer would keep. +/// +/// Uses the same path-based filters as [`crate::incremental::apply_fs_event`], +/// so we never hold a descriptor for a directory whose events would be +/// discarded on arrival. +fn watch_if_new_dir(ctx: &LoopCtx, path: &Path) { + // A recursive root watch already covers anything created beneath it. + if crate::platform::WATCH_ROOTS_RECURSIVELY { + return; + } + // Files are reported through their parent's watch; only directories + // need one of their own. + if !path.is_dir() { + return; + } + if !is_event_interesting(ctx, path) { + return; + } + register_tree(ctx, path); +} + +/// Drop watches for a directory that went away. Cheap no-op for files. +fn unwatch_tree(ctx: &LoopCtx, path: &Path) { + // Nothing per-directory was ever registered, and the root's own watch + // must outlive a deleted subdirectory. + if crate::platform::WATCH_ROOTS_RECURSIVELY { + return; + } + let mut reg = ctx.registry.lock().unwrap(); + if reg.remove_tree(path) > 0 { + ctx.dir_count.store(reg.dirs.len(), Ordering::Relaxed); + } +} + +/// Whether an event for `path` is worth queueing at all. +/// +/// The same predicate the walk applies, so the watcher and the indexer agree +/// on which subtrees exist. Where a platform registers watches per directory +/// this is mostly redundant — those subtrees were never watched — but under a +/// recursive root watch it is the *only* thing keeping `node_modules` churn +/// out of the throttle map. Applying it on both keeps one code path and drops +/// ignored events a debounce window earlier than +/// [`crate::incremental::apply_fs_event`] would. +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) + { + return false; + } + true +} + fn handle_notify_event( ev: &NotifyEvent, throttle: &mut HashMap, - sink: &EventSink, + ctx: &LoopCtx, ) { // Rename events that carry both sides are emitted directly — they // can't be coalesced with same-dir creates/modifies meaningfully. if let EventKind::Modify(notify::event::ModifyKind::Name(kind)) = ev.kind { if matches!(kind, notify::event::RenameMode::Both) && ev.paths.len() == 2 { - sink(FsEvent::Rename { + // A rename is only uninteresting when *both* ends are: moving a + // 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]) + { + return; + } + unwatch_tree(ctx, &ev.paths[0]); + watch_if_new_dir(ctx, &ev.paths[1]); + (ctx.sink)(FsEvent::Rename { from: ev.paths[0].clone(), to: ev.paths[1].clone(), }); @@ -265,6 +653,9 @@ fn handle_notify_event( } for p in &ev.paths { + if !is_event_interesting(ctx, p) { + continue; + } let op = match ev.kind { EventKind::Create(_) => QueuedOp::Create, EventKind::Remove(_) => QueuedOp::Remove, @@ -277,6 +668,14 @@ fn handle_notify_event( EventKind::Modify(_) => QueuedOp::Modify, _ => continue, }; + // Keep the watch set in step with the tree before debouncing: a + // directory created now may be populated before the throttle + // window expires, and those child events need its watch in place. + match op { + QueuedOp::Create => watch_if_new_dir(ctx, p), + QueuedOp::Remove => unwatch_tree(ctx, p), + QueuedOp::Modify => {} + } enqueue(throttle, p.clone(), op); } } @@ -358,15 +757,50 @@ fn prune_stale(throttle: &mut HashMap, max_age: Durat #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex; + use std::sync::Mutex as StdMutex; - fn sink_to_vec() -> (EventSink, Arc>>) { - let v: Arc>> = Arc::new(Mutex::new(Vec::new())); + fn sink_to_vec() -> (EventSink, Arc>>) { + let v: Arc>> = Arc::new(StdMutex::new(Vec::new())); let v_clone = v.clone(); let s: EventSink = Arc::new(move |e| v_clone.lock().unwrap().push(e)); (s, v) } + /// Filters matching the shipped defaults: hidden excluded, `.git` and + /// `node_modules` ignored. + fn default_filters() -> WatchFilters { + WatchFilters { + include_hidden: false, + follow_symlinks: false, + ignore: Arc::new( + IgnoreSet::compile(&[".git".to_string(), "node_modules".to_string()]).unwrap(), + ), + } + } + + fn fast_config() -> WatcherConfig { + WatcherConfig { + throttle_window: Duration::from_millis(50), + tick_interval: Duration::from_millis(20), + ..WatcherConfig::default() + } + } + + /// Unique temp directory; the repo has no `tempfile` dev-dependency. + fn tmp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "qs-watch-{}-{}-{}", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + #[test] fn enqueue_create_then_remove_cancels() { let mut map: HashMap = HashMap::new(); @@ -453,23 +887,11 @@ mod tests { /// flaky on slow CI, increase the sleeps. #[test] fn e2e_create_modify_remove_surfaces() { - let dir = std::env::temp_dir().join(format!( - "qs-watch-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir(&dir).unwrap(); - + let dir = tmp_dir("e2e"); let (sink, got) = sink_to_vec(); - let mut config = WatcherConfig::default(); - // Speed the test up: small window, small tick. - config.throttle_window = Duration::from_millis(50); - config.tick_interval = Duration::from_millis(20); - let mut w = Watcher::start(std::iter::once(&dir), 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(); @@ -493,4 +915,297 @@ mod tests { std::fs::remove_dir_all(&dir).ok(); } + + /// The whole point of the rewrite: descriptors are not spent on + /// directories the indexer would discard. + #[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"] { + 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(); + + // root + keep + keep/nested. The 4 ignored/hidden dirs cost nothing. + assert_eq!( + w.watched_dirs(), + 3, + "expected root, keep, keep/nested only" + ); + drop(w); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn include_hidden_registers_dotted_dirs() { + let dir = tmp_dir("hidden"); + std::fs::create_dir_all(dir.join(".hidden")).unwrap(); + + let filters = WatchFilters { + include_hidden: true, + ..default_filters() + }; + 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); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Exceeding the cap fails the whole registration — no root gets + /// partial live updates. + #[test] + fn exceeding_the_cap_fails_all_or_nothing() { + let dir = tmp_dir("cap"); + for sub in ["a", "b", "c", "d"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + } + + let config = WatcherConfig { + max_watched_dirs: 2, + ..fast_config() + }; + let err = Watcher::start(std::iter::once(&dir), default_filters(), config, sink_to_vec().0) + .unwrap_err(); + + assert_eq!( + err, + WatchError::TooManyDirectories { dirs: 2, cap: 2 }, + "5 directories under a cap of 2 must fail" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// One folder the user cannot read is not a reason to switch live + /// updates off for every root — it costs its own events only. + #[test] + #[cfg(unix)] + fn an_unreadable_directory_is_skipped_not_fatal() { + let dir = tmp_dir("denied"); + std::fs::create_dir_all(dir.join("open")).unwrap(); + let locked = dir.join("locked"); + 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); + crate::platform::restore_read(&locked).ok(); + let w = started.expect("an unreadable directory must not fail the watcher"); + + assert_eq!(w.watched_dirs(), 2, "root + open; locked is skipped"); + assert!(!w.is_degraded()); + drop(w); + std::fs::remove_dir_all(&dir).ok(); + } + + /// The bug this guards: an unreadable directory aborted registration + /// with its own error, so a tree that was *also* over the cap reported + /// "permission denied" as the reason live updates were off — hiding the + /// one limit the user can actually act on. + #[test] + #[cfg(unix)] + fn the_cap_outranks_an_unreadable_directory() { + let dir = tmp_dir("denied-cap"); + for sub in ["a", "b", "c", "d"] { + std::fs::create_dir_all(dir.join(sub)).unwrap(); + } + let locked = dir.join("locked"); + std::fs::create_dir_all(&locked).unwrap(); + crate::platform::deny_read(&locked).unwrap(); + + let config = WatcherConfig { + max_watched_dirs: 2, + ..fast_config() + }; + 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. + assert_eq!( + started.unwrap_err(), + WatchError::TooManyDirectories { dirs: 2, cap: 2 }, + "the reported reason must be the cap, not the unreadable folder" + ); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_tree_inside_the_cap_registers() { + let dir = tmp_dir("undercap"); + std::fs::create_dir_all(dir.join("a")).unwrap(); + + let config = WatcherConfig { + max_watched_dirs: 2, + ..fast_config() + }; + 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); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Regression guard for taking over recursion from notify: a directory + /// created after startup must get its own watch, or its contents are + /// invisible to live updates. + #[test] + 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(); + assert_eq!(w.watched_dirs(), 1, "only the root to begin with"); + + let sub = dir.join("later"); + std::fs::create_dir(&sub).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(w.watched_dirs(), 2, "the new directory must be watched"); + + // A file inside it is only visible if that watch really landed. + let f = sub.join("inside.txt"); + std::fs::write(&f, "hi").unwrap(); + std::thread::sleep(Duration::from_millis(300)); + w.stop(); + + let events = got.lock().unwrap().clone(); + assert!( + events.iter().any(|e| matches!( + e, + FsEvent::Create(p) | FsEvent::Modify(p) if p == &f + )), + "no event for the file in the new directory: {:?}", + events + ); + std::fs::remove_dir_all(&dir).ok(); + } + + /// A whole tree can arrive in one event; every directory in it needs a + /// watch, not just the top. + #[test] + fn a_moved_in_tree_registers_every_directory() { + let staging = tmp_dir("staging"); + let dir = tmp_dir("movein"); + std::fs::create_dir_all(staging.join("tree/one/two")).unwrap(); + + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink_to_vec().0, + ) + .unwrap(); + assert_eq!(w.watched_dirs(), 1); + + std::fs::rename(staging.join("tree"), dir.join("tree")).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + + assert_eq!(w.watched_dirs(), 4, "root + tree + tree/one + tree/one/two"); + w.stop(); + std::fs::remove_dir_all(&dir).ok(); + std::fs::remove_dir_all(&staging).ok(); + } + + #[test] + fn a_removed_directory_releases_its_watches() { + let dir = tmp_dir("rmdir"); + std::fs::create_dir_all(dir.join("gone/deep")).unwrap(); + + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink_to_vec().0, + ) + .unwrap(); + assert_eq!(w.watched_dirs(), 3, "root + gone + gone/deep"); + + std::fs::remove_dir_all(dir.join("gone")).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + + assert_eq!(w.watched_dirs(), 1, "descendants released with the parent"); + w.stop(); + std::fs::remove_dir_all(&dir).ok(); + } + + /// `Path::starts_with` compares components, so a sibling sharing a name + /// prefix must survive its neighbour's removal. + #[test] + fn remove_tree_does_not_match_name_prefixes() { + let dir = tmp_dir("prefix"); + std::fs::create_dir_all(dir.join("b")).unwrap(); + std::fs::create_dir_all(dir.join("bc")).unwrap(); + + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink_to_vec().0, + ) + .unwrap(); + assert_eq!(w.watched_dirs(), 3); + + std::fs::remove_dir_all(dir.join("b")).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + + assert_eq!(w.watched_dirs(), 2, "root + bc; /a/bc is not under /a/b"); + w.stop(); + std::fs::remove_dir_all(&dir).ok(); + } + + /// Crossing the cap at runtime (rather than at startup) must record + /// *which* limit was hit, so the coordinator doesn't have to guess. + #[test] + fn running_out_of_budget_later_records_the_reason() { + let dir = tmp_dir("degrade"); + let config = WatcherConfig { + max_watched_dirs: 2, + ..fast_config() + }; + 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. + std::fs::create_dir(dir.join("fits")).unwrap(); + std::thread::sleep(Duration::from_millis(200)); + std::fs::create_dir(dir.join("overflows")).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + + assert_eq!( + w.degraded_reason(), + Some(WatchError::TooManyDirectories { dirs: 2, cap: 2 }), + "the cap, not a kernel limit" + ); + assert_eq!(w.watched_dirs(), 2, "never registers past the cap"); + w.stop(); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn a_created_ignored_directory_is_not_watched() { + let dir = tmp_dir("newignored"); + let mut w = Watcher::start( + std::iter::once(&dir), + default_filters(), + fast_config(), + sink_to_vec().0, + ) + .unwrap(); + + std::fs::create_dir(dir.join("node_modules")).unwrap(); + std::fs::create_dir(dir.join(".cache")).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + + assert_eq!(w.watched_dirs(), 1, "neither ignored nor hidden dirs count"); + w.stop(); + std::fs::remove_dir_all(&dir).ok(); + } } diff --git a/crates/quicksearch-core/tests/cascade.rs b/crates/quicksearch-core/tests/cascade.rs new file mode 100644 index 0000000..33a78a5 --- /dev/null +++ b/crates/quicksearch-core/tests/cascade.rs @@ -0,0 +1,1015 @@ +//! Integration tests for the ranked search cascade and the streaming +//! search service, against real temp databases. + +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +use quicksearch_core::db::open_or_recreate; +use quicksearch_core::db::repo::{insert_file, set_content_done, NewFile}; +use quicksearch_core::mime::FileType; +use quicksearch_core::query::split::split_for_cascade; +use quicksearch_core::search::{cascade, SearchHit, SearchOptions, SearchService, SearchUpdate}; + +fn tmp_db(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "qs-cascade-{}-{}-{}.sqlite", + tag, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + p +} + +struct Seeder { + conn: rusqlite::Connection, + store_text: bool, +} + +impl Seeder { + fn new(path: &PathBuf, store_text: bool) -> Seeder { + Seeder { + conn: open_or_recreate(path.to_str().unwrap(), "trigram").unwrap(), + store_text, + } + } + + /// Insert a file; `text: Some(..)` also content-indexes it. + fn add(&mut self, name: &str, dir: &str, mtime: u64, text: Option<&str>) -> i64 { + let path = format!("{}/{}", dir, name); + let tx = self.conn.transaction().unwrap(); + let id = insert_file( + &tx, + &NewFile { + name, + path: &path, + parent: dir, + size: 42, + mtime, + inode: None, + device_id: None, + mime: Some("text/plain"), + ftype: FileType::TEXT, + hash: None, + }, + ) + .unwrap() + .expect("unique path"); + if let Some(text) = text { + set_content_done(&tx, id, name, text, &[], self.store_text).unwrap(); + } + tx.commit().unwrap(); + id + } + + fn done(self) -> rusqlite::Connection { + self.conn + } +} + +/// Run the cascade synchronously, collecting every batch. Returns +/// (flattened hits in emission order, outcome). +fn run_collect( + conn: &rusqlite::Connection, + input: &str, + options: &SearchOptions, +) -> (Vec, cascade::Outcome) { + let split = split_for_cascade(input).expect("split"); + let latest = AtomicU64::new(7); + let mut hits = Vec::new(); + let outcome = cascade::run(conn, &split, options, 7, &latest, &mut |batch| { + hits.extend(batch) + }) + .expect("cascade run") + .expect("not cancelled"); + (hits, outcome) +} + +fn fuzzy_options() -> SearchOptions { + SearchOptions { + fuzzy: true, + ..SearchOptions::default() + } +} + +fn fuzzy_options_with_edits(max_edits: usize) -> SearchOptions { + SearchOptions { + fuzzy: true, + fuzzy_max_edits: max_edits, + ..SearchOptions::default() + } +} + +#[test] +fn rank_classification_across_all_stages() { + let p = tmp_db("ranks"); + let mut s = Seeder::new(&p, true); + let rank1 = s.add("Report", "/a", 1, None); + let rank2 = s.add("report", "/b", 2, None); + let rank3 = s.add("Quarterly_Report.txt", "/c", 3, None); + let rank4 = s.add("quarterly_report.txt", "/d", 4, None); + let rank5 = s.add("notes-cs.txt", "/e", 5, Some("the Report was filed today")); + let rank6 = s.add("notes-ci.txt", "/f", 6, Some("the report was filed today")); + let rank7 = s.add("Reprot.txt", "/g", 7, None); // 2 substitutions + let rank8 = s.add("body-fuzzy.txt", "/h", 8, Some("the reoprt went missing")); + // Path tiers: the term is in the directory, never in the name. + let rank9 = s.add("alpha.bin", "/Report-archive", 9, None); + let rank10 = s.add("beta.bin", "/report-archive", 10, None); + let rank11 = s.add("gamma.bin", "/Reprot-archive", 11, None); + let _miss = s.add("unrelated.bin", "/z", 12, Some("nothing to see")); + let conn = s.done(); + + let (hits, outcome) = run_collect(&conn, "Report", &fuzzy_options()); + let order: Vec<(i64, u8)> = hits.iter().map(|h| (h.file_id, h.stage)).collect(); + assert_eq!( + order, + vec![ + (rank1, 1), + (rank2, 2), + (rank3, 3), + (rank4, 4), + (rank5, 5), + (rank6, 6), + (rank7, 7), + (rank8, 8), + (rank9, 9), + (rank10, 10), + (rank11, 11), + ], + "each fixture lands at its designed rank, in order" + ); + assert_eq!(outcome.total, 11); + assert!(!outcome.limited); + + // Rank monotonicity across the whole emission stream. + for pair in hits.windows(2) { + assert!( + pair[0].rank <= pair[1].rank, + "ranks must never decrease: {} then {}", + pair[0].rank, + pair[1].rank + ); + } + + // Full-text hits carry snippets with valid ranges. + 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()); + } + } + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn path_substring_tiers_split_by_case() { + let p = tmp_db("pathcase"); + let mut s = Seeder::new(&p, true); + let exact = s.add("a.bin", "/Vacation-2024", 1, None); + let anycase = s.add("b.bin", "/vacation-2023", 2, None); + let _miss = s.add("c.bin", "/holiday", 3, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "Vacation", &SearchOptions::default()); + assert_eq!( + hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + vec![(exact, 9), (anycase, 10)], + "exact-case path matches outrank any-case ones" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn path_hits_carry_a_highlighted_path_snippet() { + let p = tmp_db("pathsnip"); + let mut s = Seeder::new(&p, true); + s.add("a.bin", "/srv/Vacation/raw", 1, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "Vacation", &SearchOptions::default()); + assert_eq!(hits.len(), 1); + let hit = &hits[0]; + let snip = hit.snippet.as_ref().expect("path hits mark the match"); + assert_eq!(snip.window, hit.path, "the window is the whole path"); + assert_eq!(snip.ranges.len(), 1); + let (a, b) = snip.ranges[0]; + assert_eq!(&snip.window[a..b], "Vacation"); + assert!(!snip.truncated_start && !snip.truncated_end); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn path_match_is_deduped_against_a_better_name_match() { + let p = tmp_db("pathdedup"); + let mut s = Seeder::new(&p, true); + // Name matches at rank 3 and the path would match at 9 as well. + let star = s.add("Budget.txt", "/Budget/2024", 1, None); + let conn = s.done(); + + let (hits, outcome) = run_collect(&conn, "Budget", &fuzzy_options()); + assert_eq!(outcome.total, 1); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].file_id, star); + assert_eq!(hits[0].stage, 3, "the name tier wins"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn path_tiers_respect_the_three_char_floor() { + let p = tmp_db("pathfloor"); + let mut s = Seeder::new(&p, true); + let dir_only = s.add("z.bin", "/abcdir", 1, None); + let name_hit = s.add("ab.txt", "/d", 2, None); + let conn = s.done(); + + // Below the floor nothing path-shaped leaks in, fuzzy tier included. + let (short, _) = run_collect(&conn, "ab", &fuzzy_options()); + assert_eq!( + short.iter().map(|h| h.file_id).collect::>(), + vec![name_hit], + "2-char term: no path matching at all" + ); + + // At the floor the substring tier surfaces the directory match. (Fuzzy + // 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::>(), + vec![(dir_only, 9)], + "3-char term: the directory match surfaces" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn term_with_separator_matches_across_the_path() { + let p = tmp_db("pathsep"); + let mut s = Seeder::new(&p, true); + let nested = s.add("report-final.txt", "/home/docs", 1, None); + let _elsewhere = s.add("report-final.txt", "/home/other", 2, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "docs/report", &SearchOptions::default()); + assert_eq!( + hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + vec![(nested, 9)], + "a term spanning a separator can only match the full path" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn fuzzy_path_tier_requires_the_fuzzy_flag() { + let p = tmp_db("fuzzypath"); + let mut s = Seeder::new(&p, true); + let typo_dir = s.add("gamma.bin", "/Reprot-archive", 1, None); + let conn = s.done(); + + let (off, _) = run_collect(&conn, "Report", &SearchOptions::default()); + assert!(off.is_empty(), "no fuzzy stages without the flag"); + + let (on, _) = run_collect(&conn, "Report", &fuzzy_options()); + assert_eq!( + on.iter().map(|h| (h.file_id, h.stage)).collect::>(), + vec![(typo_dir, 11)] + ); + assert!((on[0].rank - 11.2).abs() < 1e-9, "2 edits adds 0.2"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn fuzzy_max_edits_widens_and_narrows_the_budget() { + let p = tmp_db("fuzzybudget"); + let mut s = Seeder::new(&p, true); + let two_edits = s.add("quartrely.txt", "/d", 1, None); + let three_edits = s.add("quxxxerly.txt", "/d", 2, None); + let conn = s.done(); + + let (default, _) = run_collect(&conn, "quarterly", &fuzzy_options()); + assert_eq!( + default.iter().map(|h| h.file_id).collect::>(), + vec![two_edits], + "the default budget of 2 can't reach a 3-edit typo" + ); + assert!((default[0].rank - 7.2).abs() < 1e-9); + + let (widened, _) = run_collect(&conn, "quarterly", &fuzzy_options_with_edits(3)); + assert_eq!( + widened.iter().map(|h| h.file_id).collect::>(), + vec![two_edits, three_edits], + "raising the cap admits the 3-edit typo, ranked after the closer one" + ); + assert!((widened[1].rank - 7.3).abs() < 1e-9); + + let (strict, _) = run_collect(&conn, "quarterly", &fuzzy_options_with_edits(1)); + assert!(strict.is_empty(), "a cap of 1 rejects both typos"); + + let (off, _) = run_collect(&conn, "quarterly", &fuzzy_options_with_edits(0)); + assert!(off.is_empty(), "a cap of 0 disables the fuzzy stages"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn dedup_keeps_best_rank() { + let p = tmp_db("dedup"); + let mut s = Seeder::new(&p, true); + // Exact-case filename match whose body also contains the term: would + // hit ranks 1, 3 (substring of itself is exact) and 5 — must appear + // once, at rank 1. + let star = s.add("Budget", "/a", 1, Some("Budget Budget Budget")); + let conn = s.done(); + + let (hits, outcome) = run_collect(&conn, "Budget", &fuzzy_options()); + assert_eq!(outcome.total, 1); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].file_id, star); + assert_eq!(hits[0].stage, 1); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn occurrence_counts_order_within_rank() { + let p = tmp_db("frac"); + 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 conn = s.done(); + + let (hits, _) = run_collect(&conn, "zebra", &SearchOptions::default()); + let ids: Vec = hits.iter().map(|h| h.file_id).collect(); + assert_eq!( + ids, + vec![thousand, three, one], + "more occurrences sorts earlier within the rank" + ); + assert_eq!(hits[0].rank, 5.0, "1000+ occurrences adds zero"); + assert!((hits[1].rank - 5.997).abs() < 1e-9); + assert!((hits[2].rank - 5.999).abs() < 1e-9); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn like_metacharacters_are_literal() { + let p = tmp_db("like"); + let mut s = Seeder::new(&p, true); + let real = s.add("100%.txt", "/d", 1, None); + let _decoy = s.add("x100y.txt", "/d", 2, None); + let _decoy2 = s.add("100_.txt", "/d", 3, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "100%", &SearchOptions::default()); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![real], + "% in the term must not act as a wildcard" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn trigram_floor_skips_text_stages() { + let p = tmp_db("floor"); + let mut s = Seeder::new(&p, true); + let name_hit = s.add("ab.txt", "/d", 1, None); + let _text_only = s.add("body.txt", "/d", 2, Some("ab ab ab")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "ab", &fuzzy_options()); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![name_hit], + "2-char term: filename stages only (fuzzy also skipped below 3)" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn diacritic_fts_candidates_are_dropped() { + let p = tmp_db("diacritic"); + let mut s = Seeder::new(&p, true); + // trigram remove_diacritics 1 makes this an FTS candidate for "cafe", + // but the exact bytes never occur — exact full-text must drop it. + let _accented = s.add("menu.txt", "/d", 1, Some("le café est ouvert")); + let plain = s.add("plain.txt", "/d", 2, Some("the cafe is open")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "cafe", &SearchOptions::default()); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![plain] + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn contentless_mode_degrades_to_unranked_stage6() { + let p = tmp_db("notext"); + let mut s = Seeder::new(&p, false); // store_text_for_snippets = false + let doc = s.add("doc.txt", "/d", 1, Some("walrus walrus walrus")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "walrus", &fuzzy_options()); + assert_eq!(hits.len(), 1, "FTS still finds it"); + let h = &hits[0]; + assert_eq!(h.file_id, doc); + assert_eq!(h.stage, 6, "cannot case-verify without text"); + assert!((h.rank - 6.999).abs() < 1e-9, "count-unknown fraction"); + assert!(h.snippet.is_none()); + // And no fuzzy full-text stage without documents_text. + assert!(!hits.iter().any(|h| h.stage == 8)); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn filters_apply_to_every_stage() { + let p = tmp_db("filters"); + let mut s = Seeder::new(&p, true); + let keep_name = s.add("alpha.txt", "/keep", 1, None); + let _skip_name = s.add("alpha.txt", "/skip", 1, None); + let keep_text = s.add("k.txt", "/keep", 2, Some("alpha body")); + let _skip_text = s.add("s.txt", "/skip", 2, Some("alpha body")); + let keep_fuzzy = s.add("alpah.txt", "/keep", 3, None); + let _skip_fuzzy = s.add("alpah.txt", "/skip", 3, None); + let keep_path = s.add("p.bin", "/keep/alpha-sub", 4, None); + let _skip_path = s.add("p.bin", "/skip/alpha-sub", 4, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "alpha path:/keep", &fuzzy_options()); + let mut ids: Vec = hits.iter().map(|h| h.file_id).collect(); + ids.sort(); + let mut want = vec![keep_name, keep_text, keep_fuzzy, keep_path]; + want.sort(); + assert_eq!(ids, want, "the path filter must gate all stages"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn limit_truncates_and_flags() { + let p = tmp_db("limit"); + let mut s = Seeder::new(&p, true); + for i in 0..10 { + s.add(&format!("match-{:02}.txt", i), "/d", 1, None); + } + let conn = s.done(); + + let options = SearchOptions { + limit: 3, + ..SearchOptions::default() + }; + let (hits, outcome) = run_collect(&conn, "match", &options); + assert_eq!(hits.len(), 3); + assert_eq!(outcome.total, 3); + assert!(outcome.limited); + // Best-ranked (here: name-ordered within rank 3) survive. + assert_eq!(hits[0].name, "match-00.txt"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn session_ignores_hide_hits_before_the_cap() { + let p = tmp_db("ignores"); + let mut s = Seeder::new(&p, true); + let keep = s.add("keep-match.txt", "/d", 1, None); + let _log = s.add("match.log", "/d", 2, None); + let _sub = s.add("match.txt", "/d/logs", 3, None); + // Would be a rank-9 path hit, but its parent is an ignored component. + let _path_hit = s.add("z.bin", "/d/logs/match-sub", 4, None); + let conn = s.done(); + + let options = SearchOptions { + session_ignores: vec!["*.log".to_string(), "logs".to_string()], + ..SearchOptions::default() + }; + let (hits, outcome) = run_collect(&conn, "match", &options); + assert_eq!(hits.iter().map(|h| h.file_id).collect::>(), vec![keep]); + assert_eq!(outcome.total, 1, "ignored rows never count toward totals"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn empty_and_filter_only_terms_return_nothing() { + let p = tmp_db("empty"); + let mut s = Seeder::new(&p, true); + s.add("anything.txt", "/d", 1, Some("anything")); + let conn = s.done(); + + for input in ["", " ", "type:Text"] { + let (hits, outcome) = run_collect(&conn, input, &SearchOptions::default()); + assert!(hits.is_empty(), "input {:?}", input); + assert_eq!(outcome.total, 0); + } + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn hostile_terms_are_inert() { + let p = tmp_db("hostile"); + let mut s = Seeder::new(&p, true); + s.add("innocent.txt", "/d", 1, Some("innocent content")); + let conn = s.done(); + + for term in [ + "'; DROP TABLE files; --", + "\" OR 1=1 --", + // `term*` is a live wildcard now; the FTS metacharacters after it + // must still be inert. + "term* (NEAR) : ^", + "a\0b", + // Star-only and star-heavy terms must not scan-everything or error. + "*", + "****", + "* *", + &format!("{}*", "x".repeat(10_000)), + ] { + let split = split_for_cascade(term).expect("split never fails on words"); + let latest = AtomicU64::new(1); + let result = cascade::run( + &conn, + &split, + &SearchOptions::default(), + 1, + &latest, + &mut |_| {}, + ); + assert!(result.is_ok(), "term {:?}: {:?}", term, result.err()); + } + // Table survived. + let n: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 1); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn wildcard_name_ranks_through_the_same_tiers() { + let p = tmp_db("wildranks"); + let mut s = Seeder::new(&p, true); + // `report*` anchors the whole name, so these are tiers 1 and 2 … + let whole_cs = s.add("report2024.pdf", "/a", 1, None); + let whole_ci = s.add("Report2024.pdf", "/b", 2, None); + // … and a name that merely contains the pattern is tier 3/4. A + // trailing star can match nothing, so "report" mid-name counts too — + // substring semantics make the edge star free. + let sub_cs = s.add("my-report-final.txt", "/c", 3, None); + let sub_ci = s.add("my-Report-final.txt", "/d", 4, None); + let suffix = s.add("2024report.pdf", "/e", 5, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "report*", &SearchOptions::default()); + assert_eq!( + 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)], + "wildcard terms rank exactly like literal ones" + ); + + // Ordered-segment check the other way: `report*2024` must not match a + // name where 2024 precedes report. + let (ordered, _) = run_collect(&conn, "report*2024", &SearchOptions::default()); + assert!( + !ordered.iter().any(|h| h.name == "2024report.pdf"), + "segments must match in order" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn extension_glob_whole_matches_every_such_file() { + let p = tmp_db("extglob"); + let mut s = Seeder::new(&p, true); + let a = s.add("alpha.txt", "/d", 1, None); + let b = s.add("beta.txt", "/d", 2, None); + let upper = s.add("GAMMA.TXT", "/d", 3, None); + let _other = s.add("delta.pdf", "/d", 4, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "*.txt", &SearchOptions::default()); + let mut tier1: Vec = hits + .iter() + .filter(|h| h.stage == 1) + .map(|h| h.file_id) + .collect(); + tier1.sort(); + assert_eq!(tier1, vec![a, b], "every exact-case .txt is a tier-1 hit"); + assert_eq!( + hits.iter() + .filter(|h| h.stage == 2) + .map(|h| h.file_id) + .collect::>(), + vec![upper], + "case-folded whole match lands at tier 2" + ); + assert!(!hits.iter().any(|h| h.name == "delta.pdf")); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn wildcard_leaves_like_metacharacters_literal() { + let p = tmp_db("wildlike"); + let mut s = Seeder::new(&p, true); + let percent = s.add("100%.txt", "/d", 1, None); + let underscore = s.add("100_.txt", "/d", 2, None); + // `100*` must not let `%`/`_` semantics leak: this name has no "100". + let _decoy = s.add("1x0y.txt", "/d", 3, None); + let conn = s.done(); + + 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"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn wildcard_fulltext_narrows_with_fts_and_verifies_order() { + let p = tmp_db("wildtext"); + let mut s = Seeder::new(&p, true); + let ordered = s.add("a.txt", "/d", 1, Some("a wondrous world indeed")); + // FTS AND-of-segments finds this too (both trigram runs occur), but the + // pattern requires "wond" before "world" — verification drops it. + let _reversed = s.add("b.txt", "/d", 2, Some("world of wonders")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "wond*world", &SearchOptions::default()); + assert_eq!( + 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"); + assert_eq!(snip.ranges.len(), 1); + let (a, b) = snip.ranges[0]; + assert_eq!(&snip.window[a..b], "wondrous world"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn wildcard_with_short_segments_falls_back_to_a_full_scan() { + let p = tmp_db("wildshort"); + let mut s = Seeder::new(&p, true); + // `ab*cd`: no segment reaches the trigram floor, so FTS can't narrow — + // the fallback scans documents_text and pattern-verifies each row. + let hit = s.add("doc.txt", "/d", 1, Some("zz abXcd zz")); + let _miss = s.add("other.txt", "/d", 2, Some("cd before ab")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "ab*cd", &SearchOptions::default()); + assert_eq!( + hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + vec![(hit, 5)] + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn wildcard_path_tier_and_filters() { + let p = tmp_db("wildpath"); + let mut s = Seeder::new(&p, true); + let dir_hit = s.add("a.bin", "/Vacation-2024", 1, None); + let _filtered = s.add("b.bin", "/elsewhere/Vacation-2023", 2, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "Vac*tion", &SearchOptions::default()); + let mut ids: Vec = hits.iter().map(|h| h.file_id).collect(); + ids.sort(); + assert_eq!(ids, vec![dir_hit, _filtered]); + assert!(hits.iter().all(|h| h.stage == 9), "term only in the path"); + + // 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]); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn wildcard_terms_skip_the_fuzzy_stages() { + let p = tmp_db("wildfuzzy"); + let mut s = Seeder::new(&p, true); + let real = s.add("report.txt", "/d", 1, None); + // A 2-edit typo of "report": fuzzy would admit it for a literal term, + // but a wildcard term must not fuzz. + let _typo = s.add("Reprot.txt", "/d", 2, None); + let _typo_body = s.add("body.txt", "/d", 3, Some("the reoprt went missing")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, "rep*rt", &fuzzy_options()); + assert_eq!( + hits.iter().map(|h| h.file_id).collect::>(), + vec![real], + "no stage 7/8/11 hits for a wildcard term even with fuzzy on" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn contentless_wildcard_degrades_to_unranked_stage6() { + let p = tmp_db("wildnotext"); + let mut s = Seeder::new(&p, false); // store_text_for_snippets = false + let doc = s.add("doc.txt", "/d", 1, Some("walrus columns")); + let conn = s.done(); + + // Both segments clear the trigram floor, so FTS narrows; without stored + // 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::>(), + vec![(doc, 6)] + ); + assert!(hits[0].snippet.is_none()); + + // The short-segment fallback has no FTS evidence to lean on, so a + // contentless DB simply finds nothing there. + let (none, _) = run_collect(&conn, "wa*us", &SearchOptions::default()); + assert!(none.is_empty()); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn regex_only_query_hits_name_content_and_path() { + let p = tmp_db("regexonly"); + let mut s = Seeder::new(&p, true); + let by_name = s.add("qz42.txt", "/d", 1, None); + let by_content = s.add("notes.txt", "/d", 2, Some("ref qz7 in the body")); + let by_path = s.add("b.bin", "/qz99-dir", 3, None); + let _miss = s.add("plain.txt", "/d", 4, Some("nothing here")); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, r"regex:qz\d+", &SearchOptions::default()); + assert_eq!( + 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" + ); + + // Name and path hits mark the match in the field itself. + let name_snip = hits[0].snippet.as_ref().unwrap(); + let (a, b) = name_snip.ranges[0]; + assert_eq!(&name_snip.window[a..b], "qz42"); + let path_snip = hits[2].snippet.as_ref().unwrap(); + let (a, b) = path_snip.ranges[0]; + assert_eq!(&path_snip.window[a..b], "qz99"); + // Content hits get a windowed snippet around the first match. + let body_snip = hits[1].snippet.as_ref().unwrap(); + let (a, b) = body_snip.ranges[0]; + assert_eq!(&body_snip.window[a..b], "qz7"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn regex_is_case_insensitive_by_default_and_respects_filters() { + let p = tmp_db("regexci"); + let mut s = Seeder::new(&p, true); + let keep = s.add("QZ1.txt", "/keep", 1, None); + let _skip = s.add("qz2.txt", "/skip", 2, None); + 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]); + + // Inline opt-out flips it back to case-sensitive. + let (cs, _) = run_collect(&conn, r"regex:(?-i:qz)\d", &SearchOptions::default()); + assert!(!cs.iter().any(|h| h.file_id == keep)); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn regex_alongside_a_term_is_an_accept_predicate() { + let p = tmp_db("regexpred"); + let mut s = Seeder::new(&p, true); + // Term hit whose *content* satisfies the regex: kept, via the lazy + // content fetch (the regex is nowhere in its name or path). + let kept = s.add("budget-a.txt", "/d", 1, Some("code acme7 inside")); + // Term hit with no content at all: the regex can't be satisfied. + let _dropped = s.add("budget-b.txt", "/d", 2, None); + let conn = s.done(); + + let (hits, _) = run_collect(&conn, r"budget regex:acme\d", &SearchOptions::default()); + assert_eq!( + hits.iter().map(|h| (h.file_id, h.stage)).collect::>(), + vec![(kept, 3)], + "the term drives ranking; the regex gates acceptance" + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn hostile_regexes_complete_quickly() { + let p = tmp_db("regexhostile"); + let mut s = Seeder::new(&p, true); + s.add("aaa.txt", "/d", 1, Some(&"a".repeat(50_000))); + let conn = s.done(); + + // Backtracking bomb against a pathological haystack: the linear engine + // must simply finish. + let start = std::time::Instant::now(); + let (hits, _) = run_collect(&conn, r#"regex:"(a+)+$""#, &SearchOptions::default()); + assert!(!hits.is_empty(), "the all-a body does end in a run of a's"); + assert!( + start.elapsed() < std::time::Duration::from_secs(5), + "hostile regex must not blow up: took {:?}", + start.elapsed() + ); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn service_surfaces_invalid_regex_as_an_error() { + let p = tmp_db("regexerr"); + let mut s = Seeder::new(&p, true); + s.add("anything.txt", "/d", 1, None); + drop(s.done()); + + let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {})); + let generation = service.search("regex:[", SearchOptions::default()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + 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 => { + message = Some(m); + break; + } + Ok(_) => {} + Err(_) => break, + } + } + let message = message.expect("invalid regex must surface as a search error"); + assert!(message.contains("regex"), "unhelpful message: {}", message); + service.shutdown(); + + std::fs::remove_file(&p).ok(); +} + +#[test] +fn generation_bump_cancels_mid_stream() { + let p = tmp_db("cancel"); + let mut s = Seeder::new(&p, true); + for i in 0..500 { + s.add(&format!("bulk-{:04}.txt", i), "/d", 1, None); + } + let conn = s.done(); + + let split = split_for_cascade("bulk").unwrap(); + let latest = Arc::new(AtomicU64::new(3)); + let latest_for_sink = latest.clone(); + let mut batches = 0usize; + let outcome = cascade::run( + &conn, + &split, + &SearchOptions::default(), + 3, + &latest, + &mut |_batch| { + batches += 1; + // Simulate a new keystroke arriving after the first batch. + latest_for_sink.store(99, Ordering::SeqCst); + }, + ) + .unwrap(); + assert!(outcome.is_none(), "cancelled search must not complete"); + assert_eq!(batches, 1, "no further batches after the generation moved"); + + drop(conn); + std::fs::remove_file(&p).ok(); +} + +#[test] +fn service_rapid_fire_completes_only_the_last_generation() { + let p = tmp_db("service"); + let mut s = Seeder::new(&p, true); + for i in 0..2000 { + s.add( + &format!("file-{:04}.txt", i), + "/d", + 1, + Some("shared corpus body text"), + ); + } + drop(s.done()); + + let (service, updates) = SearchService::new(p.clone(), Arc::new(|| {})); + for i in 0..50 { + service.search(&format!("corpus body {}", i % 3), SearchOptions::default()); + } + // Final query that actually matches, so completion carries hits too. + let last_gen = service.search("corpus", SearchOptions::default()); + + let mut completed: Vec = Vec::new(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { + match updates.recv_timeout(std::time::Duration::from_millis(200)) { + Ok(SearchUpdate::Completed { generation, .. }) => { + completed.push(generation); + if generation == last_gen { + break; + } + } + Ok(_) => {} + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + } + } + assert_eq!( + completed.last().copied(), + Some(last_gen), + "the newest generation must be the one that completes (saw {:?})", + completed + ); + service.shutdown(); + + std::fs::remove_file(&p).ok(); +} + +#[test] +fn service_reports_missing_db_as_error() { + let missing = tmp_db("missing"); + let (service, updates) = SearchService::new(missing.clone(), Arc::new(|| {})); + let generation = service.search("anything", SearchOptions::default()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let mut got_error = false; + while std::time::Instant::now() < deadline { + match updates.recv_timeout(std::time::Duration::from_millis(200)) { + Ok(SearchUpdate::Error { generation: g, .. }) if g == generation => { + got_error = true; + break; + } + Ok(_) => {} + Err(_) => break, + } + } + assert!(got_error, "missing index must surface as a search error"); + service.shutdown(); +} diff --git a/crates/quicksearch-core/tests/full_index.rs b/crates/quicksearch-core/tests/full_index.rs new file mode 100644 index 0000000..0267f26 --- /dev/null +++ b/crates/quicksearch-core/tests/full_index.rs @@ -0,0 +1,636 @@ +//! End-to-end phase-1 tests over a real tree and a real database. +//! +//! These cover the failure mode that unit tests structurally cannot: a full +//! run deletes index rows for every path it did not see, so any walk that +//! quietly reports less than it should destroys data. That damage is +//! invisible on a first index — `existing_files` is empty, so nothing is +//! stale — and only appears on the second run. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use quicksearch_core::config::Config; +use quicksearch_core::indexing::{IndexingService, IndexingStatus}; + +fn tmp_dir(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "quicksearch-e2e-{}-{}-{}", + tag, + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )); + std::fs::create_dir_all(&p).unwrap(); + p +} + +fn touch(p: &Path, body: &[u8]) { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, body).unwrap(); +} + +/// Run one full index and wait for it to finish. +/// +/// Completion is detected via the `last_full_index` marker, which +/// `run_indexing` writes only on a successful finish. Polling the status +/// enum instead would race: a small tree finishes between two polls, so +/// `Idle` is ambiguous between "not started yet" and "already done". +fn index_once(root: &Path, db: &Path, config: &Config) { + if db.exists() { + let conn = rusqlite::Connection::open(db).unwrap(); + conn.execute("DELETE FROM schema_info WHERE key = 'last_full_index'", []) + .unwrap(); + } + + let service = IndexingService::new(); + service + .start_indexing( + vec![root.to_string_lossy().into_owned()], + db.to_string_lossy().into_owned(), + config.clone(), + ) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(120); + let mut done = false; + while Instant::now() < deadline { + if let IndexingStatus::Error(e) = service.get_status() { + panic!("indexing failed: {}", e); + } + if db.exists() { + if let Ok(conn) = rusqlite::Connection::open(db) { + if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { + done = true; + break; + } + } + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(done, "indexing did not finish within the timeout"); + service.stop_indexing().unwrap(); +} + +/// (path, mtime, content_state) for every indexed row, ordered by path. +fn rows(db: &Path) -> Vec<(String, i64, i64)> { + let conn = rusqlite::Connection::open(db).unwrap(); + let mut stmt = conn + .prepare("SELECT path, mtime, content_state FROM files ORDER BY path") + .unwrap(); + let out = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + out +} + +fn test_config() -> Config { + let config = Config::default(); + // Keep the run to phase 1 semantics we're asserting on; extraction is + // covered elsewhere. + config +} + +#[test] +fn reindexing_an_unchanged_tree_changes_nothing() { + let root = tmp_dir("stable"); + let db_dir = tmp_dir("stable-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + touch(&root.join("a.txt"), b"alpha"); + touch(&root.join("sub/b.txt"), b"bravo"); + touch(&root.join("sub/deep/c.txt"), b"charlie"); + touch(&root.join("other/d.md"), b"delta"); + + index_once(&root, &db, &config); + let first = rows(&db); + assert_eq!(first.len(), 4, "all four files indexed"); + + index_once(&root, &db, &config); + let second = rows(&db); + + // 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"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn deleted_files_are_removed_and_new_ones_added() { + let root = tmp_dir("churn"); + let db_dir = tmp_dir("churn-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + touch(&root.join("keep.txt"), b"keep"); + touch(&root.join("remove.txt"), b"remove"); + index_once(&root, &db, &config); + assert_eq!(rows(&db).len(), 2); + + std::fs::remove_file(root.join("remove.txt")).unwrap(); + touch(&root.join("added.txt"), b"added"); + index_once(&root, &db, &config); + + let names: Vec = rows(&db) + .into_iter() + .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"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn a_modified_file_is_updated_in_place() { + let root = tmp_dir("modify"); + let db_dir = tmp_dir("modify-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + let target = root.join("doc.txt"); + touch(&target, b"first"); + index_once(&root, &db, &config); + let before = rows(&db); + assert_eq!(before.len(), 1); + + // Filesystem mtime has one-second granularity in the stored value, so + // move it decisively rather than racing it. + touch(&target, b"second body, clearly different"); + let later = SystemTime::now() + Duration::from_secs(5); + filetime_set(&target, later); + + index_once(&root, &db, &config); + let after = rows(&db); + assert_eq!(after.len(), 1, "still exactly one row"); + assert_ne!(before[0].1, after[0].1, "mtime was refreshed"); + assert_eq!(before[0].0, after[0].0, "same path"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +/// Set a file's mtime without pulling in a dependency for it. +fn filetime_set(path: &Path, when: SystemTime) { + let f = std::fs::OpenOptions::new().write(true).open(path).unwrap(); + f.set_modified(when).unwrap(); + f.sync_all().unwrap(); +} + +#[test] +#[cfg(unix)] +fn an_unreadable_directory_does_not_delete_its_rows() { + // The scenario this guards: a network share or removable drive that is + // briefly unavailable. The walk sees nothing beneath it, which must not + // be read as "every file under here was deleted". + use std::os::unix::fs::PermissionsExt; + + let root = tmp_dir("blip"); + let db_dir = tmp_dir("blip-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + touch(&root.join("visible.txt"), b"visible"); + let vault = root.join("vault"); + touch(&vault.join("secret.txt"), b"secret"); + touch(&vault.join("nested/deeper.txt"), b"deeper"); + + index_once(&root, &db, &config); + assert_eq!(rows(&db).len(), 3, "all three indexed while readable"); + + std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o000)).unwrap(); + index_once(&root, &db, &config); + let during = rows(&db); + std::fs::set_permissions(&vault, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!( + during.len(), + 3, + "rows under an unreadable directory must survive, not be deleted" + ); + + // And once it is readable again, everything still lines up. + index_once(&root, &db, &config); + assert_eq!(rows(&db).len(), 3); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn stopping_mid_run_deletes_nothing() { + // Pins the end-to-end property: an interrupted run must never delete the + // rows it did not reach. + // + // Two independent guards currently provide it — `run_indexing` skips + // cleanup when the walk did not complete, and `cleanup_stale_index_entries` + // re-checks the stop flag before its first delete. This test passes with + // either one alone, so it does not prove the former is present; it is here + // to catch the day someone removes the last of them. + let root = tmp_dir("stop"); + let db_dir = tmp_dir("stop-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + for i in 0..1500 { + touch(&root.join(format!("d{}/f{:04}.txt", i % 25, i)), b"body"); + } + + index_once(&root, &db, &config); + let full = rows(&db); + assert_eq!(full.len(), 1500); + + // Start again and stop almost immediately, so the walk is cut short. + let service = IndexingService::new(); + service + .start_indexing( + vec![root.to_string_lossy().into_owned()], + db.to_string_lossy().into_owned(), + config.clone(), + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(15)); + service.stop_indexing().unwrap(); + drop(service); + std::thread::sleep(Duration::from_millis(250)); + + let after = rows(&db); + assert_eq!( + after.len(), + 1500, + "an interrupted run must not delete the rows it never got to" + ); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn a_wide_tree_indexes_every_file_exactly_once() { + // Exercises the parallel walk's chunking and termination against a real + // database, where a duplicate path would be a UNIQUE violation and a + // dropped path would be a missing row. + let root = tmp_dir("wide"); + let db_dir = tmp_dir("wide-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + let count = 900; + for i in 0..count { + touch(&root.join(format!("d{}/f{:04}.txt", i % 13, i)), b"body"); + } + + index_once(&root, &db, &config); + assert_eq!(rows(&db).len(), count, "every file indexed exactly once"); + + index_once(&root, &db, &config); + assert_eq!(rows(&db).len(), count, "and the second run is stable"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +/// Like `index_once`, but over several roots at once — the per-root +/// pipeline path. +fn index_roots_once(roots: &[&Path], db: &Path, config: &Config) { + if db.exists() { + let conn = rusqlite::Connection::open(db).unwrap(); + conn.execute("DELETE FROM schema_info WHERE key = 'last_full_index'", []) + .unwrap(); + } + let service = IndexingService::new(); + service + .start_indexing( + roots.iter().map(|r| r.to_string_lossy().into_owned()).collect(), + db.to_string_lossy().into_owned(), + config.clone(), + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(120); + let mut done = false; + while Instant::now() < deadline { + if let IndexingStatus::Error(e) = service.get_status() { + panic!("indexing failed: {}", e); + } + if db.exists() { + if let Ok(conn) = rusqlite::Connection::open(db) { + if quicksearch_core::db::repo::get_last_full_index(&conn).is_some() { + done = true; + break; + } + } + } + std::thread::sleep(Duration::from_millis(10)); + } + assert!(done, "indexing did not finish within the timeout"); + service.stop_indexing().unwrap(); +} + +#[test] +fn two_roots_walk_extract_and_clean_independently() { + let root_a = tmp_dir("multi-a"); + let root_b = tmp_dir("multi-b"); + let db_dir = tmp_dir("multi-db"); + let db = db_dir.join("index.sqlite"); + let config = test_config(); + + // 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"); + } + for i in 0..5 { + touch(&root_b.join(format!("b{:03}.txt", i)), b"bravo corpus quagmire"); + } + + index_roots_once(&[&root_a, &root_b], &db, &config); + + let conn = rusqlite::Connection::open(&db).unwrap(); + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .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)) + .unwrap(); + assert_eq!(pending, 0, "per-root extraction drained both roots"); + // Content from EACH root is searchable. + for term in ["\"xylophone\"", "\"quagmire\""] { + let hits: i64 = conn + .query_row( + "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH ?1", + [term], + |r| r.get(0), + ) + .unwrap(); + assert!(hits > 0, "content from both roots must be indexed ({})", term); + } + drop(conn); + + // Stale cleanup is global: deleting a file from the trickle root must + // remove exactly that row on the next multi-root run. + std::fs::remove_file(root_b.join("b000.txt")).unwrap(); + index_roots_once(&[&root_a, &root_b], &db, &config); + let conn = rusqlite::Connection::open(&db).unwrap(); + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM files", [], |r| r.get(0)) + .unwrap(); + assert_eq!(total, 64, "stale row swept across roots"); + + std::fs::remove_dir_all(&root_a).ok(); + std::fs::remove_dir_all(&root_b).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +// --------------------------------------------------------------------------- +// Inline extraction: the walk finishes files whose head is the whole file. +// +// `hash_length` is what decides how much of a file the walk reads, so setting +// it to 0 leaves an empty head, nothing can be extracted inline, and the run +// degrades to the pure two-pass behaviour. That makes it the control against +// which the optimised path must produce an identical index. +// --------------------------------------------------------------------------- + +/// Everything about a file's indexed content that a user can observe: its +/// state, the stored snippet body, and its property rows. +fn content_rows(db: &Path) -> Vec<(String, i64, Option, Option, String)> { + let conn = rusqlite::Connection::open(db).unwrap(); + let mut stmt = conn + .prepare( + "SELECT f.path, f.content_state, f.failure_msg, d.text_len, + COALESCE(GROUP_CONCAT(p.key || '=' || p.value, ','), '') + FROM files f + LEFT JOIN documents_text d ON d.file_id = f.id + LEFT JOIN properties p ON p.file_id = f.id + GROUP BY f.id + ORDER BY f.path", + ) + .unwrap(); + let out = stmt + .query_map([], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)) + }) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + out +} + +/// The decompressed body stored for a file, if any. +fn stored_text(db: &Path, suffix: &str) -> Option { + let conn = rusqlite::Connection::open(db).unwrap(); + let blob: Option> = conn + .query_row( + "SELECT d.text_zstd FROM documents_text d + JOIN files f ON f.id = d.file_id + WHERE f.path LIKE '%' || ?1", + [suffix], + |r| r.get(0), + ) + .ok(); + blob.map(|b| String::from_utf8(zstd::decode_all(&b[..]).unwrap()).unwrap()) +} + +/// 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("large.txt"), big.as_bytes()); + touch(&root.join("empty.txt"), b""); + // Invalid UTF-8 with a .txt extension: claimed by the plaintext extractor, + // but not decodable, so it must be reported as a failure either way. + touch(&root.join("bad.txt"), &[0x68, 0x69, 0xff, 0xfe, 0x00, 0x41]); + // No extension `infer` or `mime_guess` recognises: no extractor claims it. + 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] +fn inline_extraction_produces_an_identical_index_to_the_two_pass_path() { + let root = tmp_dir("inline-equiv"); + let db_dir = tmp_dir("inline-equiv-db"); + seed_mixed_tree(&root); + + // Control: hash_length 0 => empty head => nothing can be inlined. + let mut control = Config::default(); + control.processing.hash_length = 0; + let db_control = db_dir.join("control.sqlite"); + index_once(&root, &db_control, &control); + + // Optimised: the default head covers every small file in the tree. + let optimised = Config::default(); + let db_opt = db_dir.join("optimised.sqlite"); + index_once(&root, &db_opt, &optimised); + + assert_eq!( + content_rows(&db_control), + content_rows(&db_opt), + "inlining during the walk must not change a single indexed byte" + ); + + // And the bodies themselves round-trip identically, not just their lengths. + for f in ["small.txt", "large.txt", "note.md"] { + assert_eq!( + stored_text(&db_control, f), + stored_text(&db_opt, f), + "stored body differs for {}", + f + ); + } + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn the_head_boundary_decides_inlining_without_changing_the_result() { + let root = tmp_dir("inline-boundary"); + let db_dir = tmp_dir("inline-boundary-db"); + + // Exactly at the limit, and one byte past it. + let mut config = Config::default(); + config.processing.hash_length = 64; + let at = "x".repeat(64); + let past = "y".repeat(65); + touch(&root.join("at.txt"), at.as_bytes()); + touch(&root.join("past.txt"), past.as_bytes()); + + let db = db_dir.join("index.sqlite"); + index_once(&root, &db, &config); + + // 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)) + .unwrap(); + assert_eq!(pending, 0, "both sides of the boundary end up extracted"); + drop(conn); + + assert_eq!(stored_text(&db, "at.txt").as_deref(), Some(at.as_str())); + assert_eq!(stored_text(&db, "past.txt").as_deref(), Some(past.as_str())); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn undecodable_small_files_are_reported_as_failures_not_silently_skipped() { + let root = tmp_dir("inline-badutf8"); + let db_dir = tmp_dir("inline-badutf8-db"); + let db = db_dir.join("index.sqlite"); + + touch(&root.join("bad.txt"), &[0x68, 0x69, 0xff, 0xfe]); + index_once(&root, &db, &Config::default()); + + let conn = rusqlite::Connection::open(&db).unwrap(); + let (state, msg): (i64, Option) = conn + .query_row( + "SELECT content_state, failure_msg FROM files WHERE path LIKE '%bad.txt'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + // Inlining must not swallow the error: the walk declines to record it, so + // the content pass still opens the file and stores a reason. + assert_eq!(state, 2, "undecodable content is FAILED, not DONE or NA"); + assert!( + msg.unwrap_or_default().contains("bad.txt"), + "the failure names the file" + ); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn an_empty_file_is_done_with_no_snippet_sidecar() { + let root = tmp_dir("inline-empty"); + let db_dir = tmp_dir("inline-empty-db"); + let db = db_dir.join("index.sqlite"); + + touch(&root.join("empty.txt"), b""); + index_once(&root, &db, &Config::default()); + + let conn = rusqlite::Connection::open(&db).unwrap(); + let (state, sidecars): (i64, i64) = conn + .query_row( + "SELECT f.content_state, (SELECT COUNT(*) FROM documents_text d WHERE d.file_id = f.id) + FROM files f WHERE f.path LIKE '%empty.txt'", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(state, 1, "an empty file is extracted, not failed"); + assert_eq!(sidecars, 0, "no zstd frame for an empty body"); + + std::fs::remove_dir_all(&root).ok(); + std::fs::remove_dir_all(&db_dir).ok(); +} + +#[test] +fn the_content_extension_filter_still_excludes_small_text_files() { + let root = tmp_dir("inline-filter"); + let db_dir = tmp_dir("inline-filter-db"); + let db = db_dir.join("index.sqlite"); + + let mut config = Config::default(); + config.indexing.content_extensions = vec!["md".into()]; + touch(&root.join("kept.md"), b"kept quagmire body"); + touch(&root.join("skipped.txt"), b"skipped xylophone body"); + index_once(&root, &db, &config); + + let conn = rusqlite::Connection::open(&db).unwrap(); + let states: Vec<(String, i64)> = conn + .prepare("SELECT path, content_state FROM files ORDER BY path") + .unwrap() + .query_map([], |r| Ok((r.get(0)?, r.get(1)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect(); + for (path, state) in &states { + if path.ends_with("kept.md") { + assert_eq!(*state, 1, "an allowed extension is extracted"); + } else { + assert_eq!(*state, 3, "a filtered extension is NA, never inlined"); + } + } + drop(conn); + 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(); +} + +#[test] +fn contentless_mode_still_indexes_inlined_files_without_storing_bodies() { + let root = tmp_dir("inline-contentless"); + let db_dir = tmp_dir("inline-contentless-db"); + let db = db_dir.join("index.sqlite"); + + let mut config = Config::default(); + config.processing.store_text_for_snippets = false; + touch(&root.join("small.txt"), b"searchable xylophone body"); + index_once(&root, &db, &config); + + let conn = rusqlite::Connection::open(&db).unwrap(); + let sidecars: i64 = conn + .query_row("SELECT COUNT(*) FROM documents_text", [], |r| r.get(0)) + .unwrap(); + assert_eq!(sidecars, 0, "contentless mode stores no bodies"); + let hits: i64 = conn + .query_row( + "SELECT COUNT(*) FROM searchabletext WHERE searchabletext MATCH '\"xylophone\"'", + [], + |r| r.get(0), + ) + .unwrap(); + 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(); +} diff --git a/crates/quicksearch-core/tests/snippet_perf.rs b/crates/quicksearch-core/tests/snippet_perf.rs index d4ffebe..53d21dd 100644 --- a/crates/quicksearch-core/tests/snippet_perf.rs +++ b/crates/quicksearch-core/tests/snippet_perf.rs @@ -207,12 +207,7 @@ fn snippet_paths_perf_comparison() { let b_reps = 10; let start_b = Instant::now(); let mut rows_b_total = 0usize; - let opts = snippet::Options { - pre: "", - post: "", - ellipsis: "...", - approx_chars: 64, - }; + let opts = snippet::Options { approx_chars: 64 }; for _ in 0..b_reps { for q in QUERIES { // Contentless FTS5 returns NULL for stored columns (that's the @@ -245,7 +240,7 @@ fn snippet_paths_perf_comparison() { } None => String::new(), }; - let _snip = snippet::render(&text, &[q], &opts); + let _snip = snippet::extract(&text, &[q], &opts); rows_b_total += 1; } } diff --git a/crates/quicksearch-gui/Cargo.toml b/crates/quicksearch-gui/Cargo.toml index 85b8022..bdf7774 100644 --- a/crates/quicksearch-gui/Cargo.toml +++ b/crates/quicksearch-gui/Cargo.toml @@ -2,14 +2,52 @@ name = "quicksearch-gui" version.workspace = true edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "Fast full-text search across your files: desktop app and terminal search tool." [[bin]] name = "quicksearch" path = "src/main.rs" +# Terminal search lives in its own binary because the two want opposite +# Windows subsystems: a GUI built as a console app flashes a console window on +# every launch, and a console tool built as a GUI app cannot write to the shell +# that invoked it (cmd and PowerShell do not even wait for it). Splitting is +# the only arrangement that is correct in both cases. On Unix the distinction +# does not exist and `quicksearch` still does both. +[[bin]] +name = "quicksearch-cli" +path = "src/cli_main.rs" + [dependencies] quicksearch-core = { path = "../quicksearch-core" } -dioxus = { version = "0.5.1", features = ["desktop"] } -dioxus-desktop = "0.5.1" -tokio = { version = "1.0", features = ["time", "signal"] } +eframe = { version = "0.32", default-features = false, features = [ + "glow", + "persistence", +] } +egui = "0.32" +egui_extras = "0.32" +rfd = "0.15" +open = "5" +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +# Display backends, which only exist on Linux/BSD. `default-features = false` +# has to be repeated: feature resolution unions the two stanzas, so a single +# permissive one would switch defaults back on for every target. +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +eframe = { version = "0.32", default-features = false, features = [ + "wayland", + "x11", +] } + +# Console attachment for the GUI binary (which has no stdio when launched from +# Explorer) and VT-mode enabling for the CLI binary. 0.59 matches what eframe +# and rfd already resolve, so no extra crate is compiled. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_System_Console", +] } diff --git a/crates/quicksearch-gui/assets/app.js b/crates/quicksearch-gui/assets/app.js deleted file mode 100644 index af8d7cc..0000000 --- a/crates/quicksearch-gui/assets/app.js +++ /dev/null @@ -1,232 +0,0 @@ -// QuickSearch Application JavaScript - -// Enhanced UI interactions -document.addEventListener('DOMContentLoaded', function() { - console.log('QuickSearch UI loaded'); - - // Add loading states to buttons - function addLoadingState(button, originalText) { - button.disabled = true; - button.innerHTML = '' + originalText; - } - - function removeLoadingState(button, originalText) { - button.disabled = false; - button.innerHTML = originalText; - } - - // Enhanced form interactions - const forms = document.querySelectorAll('form'); - forms.forEach(form => { - form.addEventListener('submit', function(e) { - const submitButton = form.querySelector('button[type="submit"]'); - if (submitButton) { - addLoadingState(submitButton, submitButton.textContent); - } - }); - }); - - // Keyboard shortcuts - document.addEventListener('keydown', function(e) { - // Ctrl+F to focus search - if (e.ctrlKey && e.key === 'f') { - e.preventDefault(); - const searchInput = document.querySelector('input[type="text"]'); - if (searchInput) { - searchInput.focus(); - searchInput.select(); - } - } - - // Escape to clear search - if (e.key === 'Escape') { - const searchInput = document.querySelector('input[type="text"]'); - if (searchInput && searchInput === document.activeElement) { - searchInput.value = ''; - searchInput.blur(); - } - } - }); - - // Enhanced table interactions - function enhanceTable(table) { - // Add click-to-copy functionality for table cells - const cells = table.querySelectorAll('td'); - cells.forEach(cell => { - cell.addEventListener('click', function() { - const text = cell.textContent.trim(); - if (text && navigator.clipboard) { - navigator.clipboard.writeText(text).then(() => { - // Visual feedback - cell.style.backgroundColor = '#4CAF50'; - cell.style.color = 'white'; - setTimeout(() => { - cell.style.backgroundColor = ''; - cell.style.color = ''; - }, 200); - }); - } - }); - }); - - // Add sortable columns (basic implementation) - const headers = table.querySelectorAll('th'); - headers.forEach((header, index) => { - header.style.cursor = 'pointer'; - header.addEventListener('click', () => sortTable(table, index)); - }); - } - - // Simple table sorting - function sortTable(table, columnIndex) { - const tbody = table.querySelector('tbody'); - const rows = Array.from(tbody.querySelectorAll('tr')); - - rows.sort((a, b) => { - const aVal = a.cells[columnIndex]?.textContent.trim() || ''; - const bVal = b.cells[columnIndex]?.textContent.trim() || ''; - - // Try numeric sort first - const aNum = parseFloat(aVal); - const bNum = parseFloat(bVal); - - if (!isNaN(aNum) && !isNaN(bNum)) { - return aNum - bNum; - } - - // Fall back to string sort - return aVal.localeCompare(bVal); - }); - - // Clear tbody and re-append sorted rows - tbody.innerHTML = ''; - rows.forEach(row => tbody.appendChild(row)); - } - - // Auto-enhance any tables that appear - const observer = new MutationObserver(function(mutations) { - mutations.forEach(function(mutation) { - mutation.addedNodes.forEach(function(node) { - if (node.nodeType === 1) { // Element node - const tables = node.querySelectorAll ? node.querySelectorAll('table') : []; - tables.forEach(enhanceTable); - - if (node.tagName === 'TABLE') { - enhanceTable(node); - } - } - }); - }); - }); - - observer.observe(document.body, { childList: true, subtree: true }); - - // Enhance existing tables - document.querySelectorAll('table').forEach(enhanceTable); -}); - -// Utility functions for Rust integration -window.QuickSearch = { - // Function to show toast notifications - showToast: function(message, type = 'info') { - const toast = document.createElement('div'); - toast.className = `toast toast-${type}`; - toast.textContent = message; - toast.style.cssText = ` - position: fixed; - top: 20px; - right: 20px; - padding: 12px 20px; - border-radius: 6px; - color: white; - font-weight: 600; - z-index: 2000; - animation: slideIn 0.3s ease; - `; - - // Set background based on type - const colors = { - info: '#2196F3', - success: '#4CAF50', - warning: '#FF9800', - error: '#f44336' - }; - toast.style.backgroundColor = colors[type] || colors.info; - - document.body.appendChild(toast); - - setTimeout(() => { - toast.style.animation = 'slideOut 0.3s ease'; - setTimeout(() => { - document.body.removeChild(toast); - }, 300); - }, 3000); - }, - - // Function to update status display - updateStatus: function(status) { - const statusDisplay = document.querySelector('.status-display'); - if (statusDisplay) { - statusDisplay.textContent = status; - } - }, - - // Function to highlight search terms in results - highlightSearchTerms: function(searchTerm, container) { - if (!searchTerm || !container) return; - - const walker = document.createTreeWalker( - container, - NodeFilter.SHOW_TEXT, - null, - false - ); - - const textNodes = []; - let node; - while (node = walker.nextNode()) { - textNodes.push(node); - } - - textNodes.forEach(textNode => { - const parent = textNode.parentNode; - if (parent.tagName === 'B') return; // Skip already highlighted - - const text = textNode.textContent; - const regex = new RegExp(`(${searchTerm})`, 'gi'); - - if (regex.test(text)) { - const highlightedHTML = text.replace(regex, '$1'); - const wrapper = document.createElement('span'); - wrapper.innerHTML = highlightedHTML; - parent.replaceChild(wrapper, textNode); - } - }); - } -}; - -// Add custom CSS for toasts and animations -const style = document.createElement('style'); -style.textContent = ` - @keyframes slideIn { - from { transform: translateX(100%); opacity: 0; } - to { transform: translateX(0); opacity: 1; } - } - - @keyframes slideOut { - from { transform: translateX(0); opacity: 1; } - to { transform: translateX(100%); opacity: 0; } - } - - mark { - background: #ffeb3b; - padding: 2px 4px; - border-radius: 3px; - font-weight: bold; - } - - .toast { - box-shadow: 0 4px 12px rgba(0,0,0,0.2); - } -`; -document.head.appendChild(style); diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-128.png b/crates/quicksearch-gui/assets/icons/quicksearch-128.png new file mode 100644 index 0000000..c28edb2 Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-128.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-16.png b/crates/quicksearch-gui/assets/icons/quicksearch-16.png new file mode 100644 index 0000000..61bb16a Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-16.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-22.png b/crates/quicksearch-gui/assets/icons/quicksearch-22.png new file mode 100644 index 0000000..b2f6a91 Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-22.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-24.png b/crates/quicksearch-gui/assets/icons/quicksearch-24.png new file mode 100644 index 0000000..80d464b Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-24.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-256.png b/crates/quicksearch-gui/assets/icons/quicksearch-256.png new file mode 100644 index 0000000..1a8c693 Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-256.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-32.png b/crates/quicksearch-gui/assets/icons/quicksearch-32.png new file mode 100644 index 0000000..688f8e8 Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-32.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-48.png b/crates/quicksearch-gui/assets/icons/quicksearch-48.png new file mode 100644 index 0000000..912fe83 Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-48.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch-64.png b/crates/quicksearch-gui/assets/icons/quicksearch-64.png new file mode 100644 index 0000000..65c24f4 Binary files /dev/null and b/crates/quicksearch-gui/assets/icons/quicksearch-64.png differ diff --git a/crates/quicksearch-gui/assets/icons/quicksearch_icon.svg b/crates/quicksearch-gui/assets/icons/quicksearch_icon.svg new file mode 100644 index 0000000..a499d12 --- /dev/null +++ b/crates/quicksearch-gui/assets/icons/quicksearch_icon.svg @@ -0,0 +1,14 @@ +Fast full-text search icon, square cropA square app icon: centered text lines indenting around a magnifying glass with a lightning bolt inside. + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/quicksearch-gui/assets/index.html b/crates/quicksearch-gui/assets/index.html deleted file mode 100644 index a0f3413..0000000 --- a/crates/quicksearch-gui/assets/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - QuickSearch - File Indexer & Search - - - - - - - - - -
- - - - - diff --git a/crates/quicksearch-gui/assets/styles.css b/crates/quicksearch-gui/assets/styles.css deleted file mode 100644 index ff1ca68..0000000 --- a/crates/quicksearch-gui/assets/styles.css +++ /dev/null @@ -1,299 +0,0 @@ -/* QuickSearch Application Styles */ -body { - font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; - margin: 0; - padding: 20px; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; -} - -.app-container { - max-width: 1200px; - margin: 0 auto; - background: white; - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0,0,0,0.1); - overflow: hidden; -} - -.app-header { - background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%); - color: white; - padding: 20px; - text-align: center; -} - -.app-content { - padding: 20px; -} - -.section { - background: #f8f9fa; - border-radius: 8px; - padding: 20px; - margin-bottom: 20px; - border-left: 4px solid #4CAF50; -} - -.form-control { - width: 100%; - padding: 12px; - border: 2px solid #e0e0e0; - border-radius: 6px; - font-size: 14px; - transition: border-color 0.3s ease; - box-sizing: border-box; -} - -.form-control:focus { - outline: none; - border-color: #4CAF50; - box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); -} - -.btn { - padding: 12px 24px; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 14px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; - transition: all 0.3s ease; - margin-right: 10px; - display: inline-block; -} - -.btn-primary { - background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%); - color: white; -} - -.btn-primary:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); -} - -.btn-danger { - background: linear-gradient(90deg, #f44336 0%, #d32f2f 100%); - color: white; -} - -.btn-danger:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3); -} - -.btn-info { - background: linear-gradient(90deg, #2196F3 0%, #1976D2 100%); - color: white; -} - -.btn-info:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3); -} - -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none !important; - box-shadow: none !important; -} - -.status-display { - background: #1a1a1a; - color: #00ff00; - padding: 15px; - border-radius: 6px; - font-family: 'Courier New', monospace; - white-space: pre-wrap; - font-size: 13px; - border: 1px solid #333; - overflow-x: auto; -} - -.search-results { - background: white; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); - margin-top: 20px; -} - -.results-table { - max-height: 400px; - overflow: auto; - border: 1px solid #ddd; - border-radius: 6px; -} - -.results-table table { - width: 100%; - border-collapse: collapse; -} - -.results-table th { - background: #4CAF50; - color: white; - padding: 12px; - text-align: left; - font-weight: 600; - position: sticky; - top: 0; - border-bottom: 2px solid #45a049; -} - -.results-table td { - padding: 10px 12px; - border-bottom: 1px solid #e0e0e0; - word-break: break-all; -} - -.results-table tbody tr:hover { - background: #f5f5f5; -} - -.results-table tbody tr:nth-child(even) { - background: #fafafa; -} - -.results-table tbody tr:nth-child(even):hover { - background: #f0f0f0; -} - -/* Clickable path cells */ -.path-cell.clickable { - cursor: pointer; - color: #1976d2; - font-weight: 500; - transition: all 0.2s ease; - position: relative; -} - -.path-cell.clickable:hover { - background: #e3f2fd !important; - color: #0d47a1; - text-decoration: underline; -} - -.path-cell.clickable:active { - background: #bbdefb !important; - transform: scale(0.98); -} - - -.error-message { - background: #ffebee; - color: #c62828; - padding: 12px; - border-radius: 6px; - border-left: 4px solid #f44336; - margin: 10px 0; -} - -.modal-backdrop { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0,0,0,0.5); - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; -} - -.modal-dialog { - background: white; - padding: 30px; - border-radius: 12px; - max-width: 600px; - box-shadow: 0 20px 60px rgba(0,0,0,0.3); - animation: modalSlideIn 0.3s ease; -} - -@keyframes modalSlideIn { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -h1, h2, h3 { - margin-top: 0; - color: #333; -} - -label { - display: block; - margin-bottom: 8px; - font-weight: 600; - color: #555; -} - -.form-group { - margin-bottom: 20px; -} - -/* Highlight matched text in search results */ -.results-table b { - background: #ffeb3b; - padding: 2px 4px; - border-radius: 3px; - font-weight: bold; - color: #333; -} - -/* Loading spinner */ -.loading { - display: inline-block; - width: 20px; - height: 20px; - border: 3px solid #f3f3f3; - border-top: 3px solid #4CAF50; - border-radius: 50%; - animation: spin 1s linear infinite; - margin-right: 10px; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -/* Responsive design */ -@media (max-width: 768px) { - body { - padding: 10px; - } - - .app-container { - border-radius: 8px; - } - - .app-content { - padding: 15px; - } - - .form-control { - font-size: 16px; /* Prevents zoom on iOS */ - } - - .results-table { - font-size: 12px; - } - - .btn { - width: 100%; - margin-bottom: 10px; - margin-right: 0; - } -} diff --git a/crates/quicksearch-gui/src/app.rs b/crates/quicksearch-gui/src/app.rs new file mode 100644 index 0000000..f944b21 --- /dev/null +++ b/crates/quicksearch-gui/src/app.rs @@ -0,0 +1,721 @@ +//! Application shell: tab strip, per-frame event drains, debounce, +//! status bar, and config-change routing. + +use std::time::{Duration, Instant}; + +use quicksearch_core::cli::{index_counts, IndexCounts}; +use quicksearch_core::config::{diff_actions, nested_roots, Config}; +use quicksearch_core::coordinator::{IndexMode, IndexerState, WatcherStatus}; +use quicksearch_core::indexing::{ConfigChange, IndexingStatus, RootPhase}; +use quicksearch_core::search::SearchOptions; +use quicksearch_core::watcher::WatchError; + +use crate::backend::Backend; +use crate::duplicates_tab::{DupState, DuplicatesTab}; +use crate::format::{fmt_interval, group_thousands}; +use crate::logs_tab::LogsTab; +use crate::manage_tab::ManageTab; +use crate::options::OptionsWindow; +use crate::search_tab::SearchTab; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Tab { + Search, + Manage, + Duplicates, + Logs, +} + +pub struct QuickSearchApp { + cfg: Config, + backend: Backend, + tab: Tab, + search: SearchTab, + manage: ManageTab, + dups: DuplicatesTab, + logs: LogsTab, + options: OptionsWindow, + /// Cached idle counts for the status bar, refreshed at most every 5 s. + counts: Option<(Instant, IndexCounts)>, + /// Set when applying a config that invalidates the stored index. + rebuild_prompt: Option>, + /// Set while the "delete the index?" confirmation is open. + clear_prompt: bool, + /// Nested roots found in the loaded config (startup validation); shown + /// as a modal over the Manage tab until dismissed. + nested_prompt: Option>, + /// Set when the watcher gave up on the directory budget and live + /// updates are off; see [`QuickSearchApp::check_watch_cap_warning`]. + watch_cap_prompt: Option, + config_error: Option, +} + +impl QuickSearchApp { + /// `initial_query` pre-fills the search box and fires a search on the + /// first frame. It carries the positional arguments the binary was given, + /// which on Windows is the only thing the GUI can do with them — terminal + /// output belongs to `quicksearch-cli` there. + pub fn new( + cc: &eframe::CreationContext<'_>, + cfg: Config, + config_error: Option, + initial_query: Option, + ) -> Result { + // Compact styling: results density is the whole point. + cc.egui_ctx.style_mut(|style| { + style.spacing.item_spacing = egui::vec2(6.0, 3.0); + style.spacing.button_padding = egui::vec2(6.0, 2.0); + }); + cc.egui_ctx.set_zoom_factor(clamp_scale(cfg.ui.scale)); + + let backend = Backend::start(&cfg, cc.egui_ctx.clone())?; + let fuzzy = cfg.search.fuzzy_default; + // Startup validation: a hand-edited config can nest roots, which + // per-root pipelines can't accept. Redirect straight to the folder + // list with an explanatory modal; the coordinator refuses runs + // until it's fixed. + let nested = nested_roots(&cfg.paths.indexing_paths); + let (tab, nested_prompt) = if nested.is_empty() { + (Tab::Search, None) + } else { + (Tab::Manage, Some(nested)) + }; + let mut search = SearchTab::new(fuzzy); + if let Some(query) = initial_query { + search.seed(query); + } + Ok(QuickSearchApp { + cfg, + backend, + tab, + search, + manage: ManageTab::new(), + dups: DuplicatesTab::new(), + logs: LogsTab::new(), + options: OptionsWindow::new(), + counts: None, + rebuild_prompt: None, + clear_prompt: false, + nested_prompt, + watch_cap_prompt: None, + config_error, + }) + } + + fn search_options(&self) -> SearchOptions { + SearchOptions { + fuzzy: self.search.fuzzy, + fuzzy_max_edits: self.cfg.search.fuzzy_max_edits, + limit: self.cfg.search.display_limit, + batch: self.cfg.search.results_per_page.max(1), + session_ignores: self.search.session_ignores.clone(), + } + } + + fn start_search(&mut self) { + let generation = self + .backend + .search() + .search(&self.search.query, self.search_options()); + self.search.on_search_started(generation); + } + + fn start_duplicates_scan(&mut self, ctx: &egui::Context) { + self.dups.state = DupState::Loading; + let cfg = self.cfg.clone(); + 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) { + 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; + } + // Warned-root memory only means anything for folders still indexed. + // Pruning here is what makes removing and re-adding a folder warn + // again rather than staying silently suppressed forever. + new.ui + .watch_cap_warned_roots + .retain(|root| new.paths.indexing_paths.contains(root)); + let actions = diff_actions(&self.cfg, &new); + if let Err(e) = new.save() { + self.config_error = Some(e); + } + if (new.ui.scale - self.cfg.ui.scale).abs() > f32::EPSILON { + ctx.set_zoom_factor(clamp_scale(new.ui.scale)); + } + if actions.search_db_changed { + self.backend + .search() + .set_db_path(new.resolved_database_path()); + self.counts = None; + } + self.backend.coordinator.apply_config(new.clone()); + if actions.requires_rebuild { + if self.backend.coordinator.state().mode == IndexMode::Auto { + // Automatic mode is hands-off: reconcile immediately, no + // prompt. Root-only changes need just a full run — the + // walk indexes new roots and the stale sweep drops removed + // ones. Anything else (tokenizer, hashing, filters, hidden + // files) invalidates stored data and gets the real wipe. + let roots_only = { + let mut probe = new.clone(); + probe.paths.indexing_paths = self.cfg.paths.indexing_paths.clone(); + !diff_actions(&self.cfg, &probe).requires_rebuild + }; + if roots_only { + self.backend.coordinator.reindex_now(); + } else { + self.backend.coordinator.rebuild_index(); + } + } else { + let changes = self + .backend + .coordinator + .check_config_validation(&new) + .ok() + .flatten() + .unwrap_or_default(); + self.rebuild_prompt = Some(changes); + } + } + self.cfg = new; + self.manage.invalidate_editors(); + } + + fn drain_events(&mut self) { + // Streamed search results. + loop { + match self.backend.search_rx.try_recv() { + Ok(update) => self + .search + .apply_update(update, self.cfg.search.display_limit), + Err(_) => break, + } + } + // Duplicates worker. + if let Some(rx) = &self.backend.dup_job { + match rx.try_recv() { + Ok(Ok(groups)) => { + self.dups.state = DupState::Loaded(groups); + self.backend.dup_job = None; + } + Ok(Err(e)) => { + self.dups.state = DupState::Error(e); + self.backend.dup_job = None; + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + self.dups.state = DupState::Error("duplicates scan aborted".into()); + self.backend.dup_job = None; + } + } + } + } + + fn tick_debounce(&mut self, ctx: &egui::Context) { + let Some(edited_at) = self.search.pending_edit else { + return; + }; + let debounce = Duration::from_millis(self.cfg.search.debounce_ms); + let elapsed = edited_at.elapsed(); + if elapsed >= debounce { + self.search.pending_edit = None; + self.start_search(); + } else { + ctx.request_repaint_after(debounce - elapsed); + } + } + + /// Raise the "live updates are disabled" modal when the watcher has + /// given up on the directory budget and at least one indexed folder has + /// not been warned about yet. + /// + /// Keyed on roots rather than a single dismissed flag: a restart should + /// stay quiet, but adding a folder changes the trade-off and deserves + /// the warning again. + fn check_watch_cap_warning(&mut self, state: &IndexerState) { + let WatcherStatus::Disabled { reason } = &state.watcher else { + // Recovered (e.g. the user trimmed the folder list) — retract a + // modal that is no longer true. + self.watch_cap_prompt = None; + return; + }; + // Only the budget limits warrant a modal. Other failures are + // transient and not the user's to act on; they are named in the + // status line's tooltip and logged to the Logs tab. + if !matches!( + reason, + WatchError::TooManyDirectories { .. } | WatchError::KernelLimit { .. } + ) { + return; + } + if self.watch_cap_prompt.is_some() { + return; + } + let unwarned = self + .cfg + .paths + .indexing_paths + .iter() + .any(|root| !self.cfg.ui.watch_cap_warned_roots.contains(root)); + if unwarned { + self.watch_cap_prompt = Some(reason.clone()); + } + } + + fn status_bar(&mut self, ctx: &egui::Context) { + let state = self.backend.coordinator.state(); + self.manage.observe(&state.activity); + self.check_watch_cap_warning(&state); + + egui::TopBottomPanel::bottom("status-bar").show(ctx, |ui| { + ui.horizontal(|ui| { + match &state.activity { + IndexingStatus::Idle => { + let mode = match state.mode { + IndexMode::Auto => "Auto", + IndexMode::ManualStopped => "Manual", + IndexMode::ManualRunning => "Manual", + }; + let stale = self + .counts + .map(|(at, _)| at.elapsed() > Duration::from_secs(5)) + .unwrap_or(true); + if stale { + let db = self.cfg.resolved_database_path(); + let counts = index_counts(&db.to_string_lossy()).unwrap_or( + IndexCounts { + files: 0, + content_done: 0, + content_pending: 0, + }, + ); + self.counts = Some((Instant::now(), counts)); + } + let files = self.counts.map(|(_, c)| c.files).unwrap_or(0); + ui.label( + egui::RichText::new(format!( + "Idle · {} · {} files indexed", + mode, + group_thousands(files.max(0) as u64) + )) + .small(), + ); + } + IndexingStatus::Error(e) => { + ui.colored_label( + ui.visuals().error_fg_color, + egui::RichText::new(format!("Indexing error: {}", e)).small(), + ); + } + IndexingStatus::Stopping => { + ui.label(egui::RichText::new("Stopping indexing…").small()); + } + IndexingStatus::Running { roots, .. } => { + let done = roots + .iter() + .filter(|r| r.phase == RootPhase::Done) + .count(); + let processed: usize = + roots.iter().map(|r| r.walked + r.extracted).sum(); + let totals_known = roots.iter().all(|r| r.walk_total.is_some()); + let denominator: usize = roots + .iter() + .map(|r| r.walk_total.unwrap_or(0) + r.extract_total) + .sum(); + + let mut text = if totals_known && denominator > 0 { + let frac = + (processed as f64 / denominator as f64).min(1.0); + format!( + "Indexing {} / {} ({:.0}%)", + group_thousands(processed as u64), + group_thousands(denominator as u64), + frac * 100.0 + ) + } else { + format!( + "Indexing · {} files", + group_thousands(processed as u64) + ) + }; + if roots.len() > 1 { + text.push_str(&format!(" · {}/{} roots done", done, roots.len())); + } + if let Some(rate) = self.manage.speed.files_per_sec() { + text.push_str(&format!(" · {}", crate::format::fmt_rate(rate))); + } + let active: usize = roots.iter().map(|r| r.active_workers).sum(); + let total_workers: usize = + roots.iter().map(|r| r.total_workers).sum(); + if total_workers > 0 { + text.push_str(&format!(" · {}/{} workers", active, total_workers)); + } + ui.label(egui::RichText::new(text).small()); + if totals_known && denominator > 0 { + let frac = + (processed as f32 / denominator as f32).clamp(0.0, 1.0); + ui.add(egui::ProgressBar::new(frac).desired_width(120.0)); + } else { + ui.add(egui::Spinner::new().size(12.0)); + } + } + } + + // Right corner: search result count. + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if self.tab == Tab::Search { + if let Some(label) = self.search.result_count_label() { + ui.label(egui::RichText::new(label).small().weak()); + } + } + }); + }); + }); + + // Keep painting while anything is moving. + if !matches!(state.activity, IndexingStatus::Idle | IndexingStatus::Error(_)) { + ctx.request_repaint_after(Duration::from_millis(250)); + } + // Watcher registration walks every root, so its verdict can land + // minutes after startup. Without this the warning would wait for + // the user to happen to move the mouse. + if matches!(state.watcher, WatcherStatus::Starting) { + ctx.request_repaint_after(Duration::from_millis(500)); + } + } + + fn rebuild_prompt_ui(&mut self, ctx: &egui::Context) { + let Some(changes) = &self.rebuild_prompt else { + return; + }; + let changes = changes.clone(); + let mut close = false; + egui::Window::new("Rebuild index?") + .collapsible(false) + .resizable(false) + .default_width(560.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label("These settings differ from what the index was built with:"); + ui.add_space(4.0); + if changes.is_empty() { + ui.monospace("indexing settings changed"); + } + for change in &changes { + ui.strong(format!("{}:", change.key)); + // Multi-line values (roots, patterns, extensions) are + // newline-joined — side-by-side columns keep before and + // after readable instead of one run-on arrow line. + ui.columns(2, |cols| { + cols[0].label( + egui::RichText::new("index was built with").small().weak(), + ); + cols[0].monospace(display_value(&change.stored)); + cols[1].label(egui::RichText::new("config now says").small().weak()); + cols[1].monospace(display_value(&change.current)); + }); + ui.add_space(6.0); + } + ui.label( + egui::RichText::new( + "A full rebuild applies them everywhere. Until then, existing \ + entries keep the old settings.", + ) + .small() + .weak(), + ); + ui.horizontal(|ui| { + if ui.button("Rebuild now").clicked() { + self.backend.coordinator.rebuild_index(); + close = true; + } + if ui.button("Later").clicked() { + close = true; + } + }); + }); + if close { + self.rebuild_prompt = None; + } + } +} + +impl QuickSearchApp { + fn nested_prompt_ui(&mut self, ctx: &egui::Context) { + let Some(pairs) = &self.nested_prompt else { + return; + }; + let pairs = pairs.clone(); + let mut close = false; + egui::Window::new("Indexed folders may not be nested") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label( + "Each root is indexed by its own worker pool, so one root \ + may not contain another. Fix the folder list below:", + ); + for (child, parent) in &pairs { + ui.monospace(format!("{} ⊂ {}", child, parent)); + } + ui.label( + egui::RichText::new( + "Indexing stays paused until the overlap is removed and \ + the list is applied.", + ) + .small() + .weak(), + ); + if ui.button("Fix folders").clicked() { + close = true; + } + }); + if close { + self.nested_prompt = None; + self.tab = Tab::Manage; + } + } + + fn watch_cap_prompt_ui(&mut self, ctx: &egui::Context) { + let Some(reason) = &self.watch_cap_prompt else { + return; + }; + let reason = reason.clone(); + let mut close = false; + egui::Window::new("Live index updating is disabled") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_max_width(420.0); + match &reason { + WatchError::TooManyDirectories { cap, .. } => { + ui.label(format!( + "Your indexed folders contain more than {} directories. The \ + system limits how many folders can be watched for changes at \ + once, so QuickSearch cannot update the index as files change.", + group_thousands(*cap as u64), + )); + } + WatchError::KernelLimit { registered } => { + ui.label(format!( + "The system ran out of folder watches after {} directories, so \ + QuickSearch cannot update the index as files change.", + group_thousands(*registered as u64), + )); + } + WatchError::Other(msg) => { + ui.label(format!("Live updates are unavailable: {}", msg)); + } + } + ui.add_space(4.0); + ui.label(format!( + "The index is rebuilt every {} instead. Searches keep working; \ + recent changes may take that long to appear.", + fmt_interval(self.cfg.indexing.reindex_interval_minutes), + )); + ui.label( + egui::RichText::new( + "To restore live updates, index fewer folders or exclude large \ + subfolders under Filters on the Manage Index tab.", + ) + .small() + .weak(), + ); + if ui.button("OK").clicked() { + close = true; + } + }); + if close { + self.watch_cap_prompt = None; + for root in &self.cfg.paths.indexing_paths { + if !self.cfg.ui.watch_cap_warned_roots.contains(root) { + self.cfg.ui.watch_cap_warned_roots.push(root.clone()); + } + } + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + } + + fn clear_prompt_ui(&mut self, ctx: &egui::Context) { + if !self.clear_prompt { + return; + } + let mut close = false; + egui::Window::new("Clear index?") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label("This deletes the search index database. Your files are not touched."); + ui.label( + egui::RichText::new( + "Indexing switches to manual until you start it again or return to automatic mode.", + ) + .small() + .weak(), + ); + ui.horizontal(|ui| { + if ui + .button( + egui::RichText::new("Delete index") + .color(ui.visuals().error_fg_color), + ) + .clicked() + { + self.backend.coordinator.clear_index(); + self.counts = None; + self.dups.state = DupState::NotLoaded; + close = true; + } + if ui.button("Cancel").clicked() { + close = true; + } + }); + }); + if close { + self.clear_prompt = false; + } + } +} + + +/// A stored/current config value for the rebuild prompt; list values are +/// already newline-joined and render as-is, empty means unset. +fn display_value(value: &str) -> String { + if value.trim().is_empty() { + "(none)".to_string() + } else { + value.to_string() + } +} + +/// Keep the configured UI scale within sane, recoverable bounds. +fn clamp_scale(scale: f32) -> f32 { + if scale.is_finite() { + scale.clamp(0.5, 2.5) + } else { + 1.1 + } +} + +impl eframe::App for QuickSearchApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.drain_events(); + self.tick_debounce(ctx); + self.status_bar(ctx); + + let previous_tab = 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.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 { + self.options.open_with(&self.cfg); + } + } + }); + }); + }); + // Entering the Duplicates tab kicks off a fresh scan. + if self.tab == Tab::Duplicates && previous_tab != Tab::Duplicates { + self.start_duplicates_scan(ctx); + } + + if let Some(err) = &self.config_error { + let err = err.clone(); + egui::TopBottomPanel::top("config-error").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.colored_label( + ui.visuals().error_fg_color, + format!("Config problem: {} (using defaults)", err), + ); + if ui.small_button("Dismiss").clicked() { + self.config_error = None; + } + }); + }); + } + + egui::CentralPanel::default().show(ctx, |ui| match self.tab { + Tab::Search => { + let actions = self.search.ui(ui); + if let Some(fuzzy) = actions.save_fuzzy_default { + self.cfg.search.fuzzy_default = fuzzy; + if let Err(e) = self.cfg.save() { + self.config_error = Some(e); + } + } + if let Some(pattern) = actions.persist_ignore { + let mut new_cfg = self.cfg.clone(); + if !new_cfg.indexing.ignore_patterns.contains(&pattern) { + new_cfg.indexing.ignore_patterns.push(pattern); + self.apply_new_config(ctx, new_cfg); + } + } + if actions.rerun { + self.start_search(); + } + } + Tab::Manage => { + let state = self.backend.coordinator.state(); + let actions = self.manage.ui(ui, &state, &self.cfg); + if actions.start_now { + self.backend.coordinator.reindex_now(); + } + if actions.stop { + self.backend.coordinator.set_mode(IndexMode::ManualStopped); + } + if actions.auto { + self.backend.coordinator.set_mode(IndexMode::Auto); + } + if actions.clear_index { + self.clear_prompt = true; + } + if actions.start_now || actions.stop || actions.auto { + // Keep repainting while the command lands so the state + // change is visible without wiggling the mouse — fast + // runs otherwise flash by between frames. + ui.ctx().request_repaint_after(Duration::from_millis(100)); + } + if let Some(new_cfg) = actions.apply_config { + self.apply_new_config(ctx, new_cfg); + } + } + Tab::Duplicates => { + let actions = self.dups.ui(ui); + if actions.refresh { + self.start_duplicates_scan(ctx); + } + } + Tab::Logs => self.logs.ui(ui), + }); + + if let Some(new_cfg) = self.options.ui(ctx, &self.cfg) { + self.apply_new_config(ctx, new_cfg); + } + self.rebuild_prompt_ui(ctx); + self.clear_prompt_ui(ctx); + self.nested_prompt_ui(ctx); + self.watch_cap_prompt_ui(ctx); + } + + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + self.backend.shutdown(); + } +} diff --git a/crates/quicksearch-gui/src/backend.rs b/crates/quicksearch-gui/src/backend.rs new file mode 100644 index 0000000..dffc4f2 --- /dev/null +++ b/crates/quicksearch-gui/src/backend.rs @@ -0,0 +1,71 @@ +//! Wiring between the egui thread and the core services. +//! +//! All communication is non-blocking from the UI's point of view: +//! searches stream over an mpsc receiver drained each frame, indexing +//! state is polled, and the duplicates query runs on a throwaway worker +//! thread. Core threads wake the UI through `ctx.request_repaint()`. + +use std::sync::{mpsc, Arc}; + +use quicksearch_core::config::Config; +use quicksearch_core::coordinator::IndexCoordinator; +use quicksearch_core::search::{DuplicateGroup, SearchService, SearchUpdate}; +use quicksearch_core::shutdown; + +pub struct Backend { + pub coordinator: Arc, + pub search: Option, + pub search_rx: mpsc::Receiver, + pub dup_job: Option, String>>>, +} + +impl Backend { + pub fn start(config: &Config, ctx: egui::Context) -> Result { + let coordinator = Arc::new(IndexCoordinator::start(config.clone())?); + if let Err(e) = shutdown::install_signal_handler(coordinator.clone()) { + quicksearch_core::log_warn!("failed to install signal handler: {}", e); + } + + let repaint_ctx = ctx.clone(); + let (search, search_rx) = SearchService::new( + config.resolved_database_path(), + Arc::new(move || repaint_ctx.request_repaint()), + ); + + Ok(Backend { + coordinator, + search: Some(search), + search_rx, + dup_job: None, + }) + } + + pub fn search(&self) -> &SearchService { + self.search.as_ref().expect("search service alive") + } + + /// Kick off (or restart) the duplicates listing on a worker thread. + pub fn start_duplicates(&mut self, config: &Config, ctx: egui::Context) { + let (tx, rx) = mpsc::channel(); + let db = config.resolved_database_path(); + std::thread::spawn(move || { + let result = quicksearch_core::search::find_duplicate_groups( + &db.to_string_lossy(), + 500, + 0, + ); + let _ = tx.send(result); + ctx.request_repaint(); + }); + self.dup_job = Some(rx); + } + + /// Join the search worker and stop the coordinator. Called once from + /// `on_exit`. + pub fn shutdown(&mut self) { + if let Some(search) = self.search.take() { + search.shutdown(); + } + self.coordinator.shutdown(); + } +} diff --git a/crates/quicksearch-gui/src/cli.rs b/crates/quicksearch-gui/src/cli.rs new file mode 100644 index 0000000..72c3db1 --- /dev/null +++ b/crates/quicksearch-gui/src/cli.rs @@ -0,0 +1,230 @@ +//! Terminal query mode: `quicksearch [FLAGS] ` runs the +//! same ranked cascade the GUI uses and prints results to stdout. With no +//! positional arguments the binary opens the GUI instead. + +use std::io::IsTerminal; +use std::sync::atomic::AtomicU64; + +use quicksearch_core::config::Config; +use quicksearch_core::db; +use quicksearch_core::query::split::split_for_cascade; +use quicksearch_core::search::{cascade, SearchHit, SearchOptions}; + +use crate::format::{fmt_mtime, human_size}; + +pub(crate) const USAGE: &str = "\ +QuickSearch: indexed file search + +USAGE: + quicksearch open the GUI + quicksearch [FLAGS] search from the terminal + (Windows: quicksearch-cli) + +FLAGS: + --fuzzy also run the fuzzy filename/full-text passes + --limit maximum results (default: [search].display_limit) + --long rank, size, mtime, and snippets instead of bare paths + -h, --help this help + +Query syntax matches the GUI: plain words form one phrase; filters like +type:Document, modified:>=2024-01-01, path:/dir, mime:application/pdf, +name:frag combine with it."; + +/// Parse argv; `Some(exit_code)` when the invocation was CLI-mode (query +/// or --help), `None` to open the GUI. +/// +/// Invariant: terminal mode never builds an [`IndexCoordinator`], so it +/// starts no filesystem watcher, no background threads, and consumes no +/// inotify watches — a one-shot query must not leave anything running or +/// compete for the per-user watch budget with a running GUI. It opens the +/// database, queries, prints, and exits. Keep it that way: the coordinator +/// belongs to the GUI path in `backend.rs` alone. +/// +/// [`IndexCoordinator`]: quicksearch_core::coordinator::IndexCoordinator +pub fn maybe_run_cli() -> Option { + let args: Vec = std::env::args().skip(1).collect(); + + let mut fuzzy = false; + let mut long = false; + let mut limit: Option = None; + let mut terms: Vec = Vec::new(); + + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "-h" | "--help" => { + println!("{}", USAGE); + return Some(0); + } + "--fuzzy" => fuzzy = true, + "--long" => long = true, + "--limit" => match it.next().and_then(|v| v.parse().ok()) { + Some(n) => limit = Some(n), + None => { + eprintln!("--limit requires a number\n\n{}", USAGE); + return Some(2); + } + }, + other if other.starts_with("--limit=") => { + match other["--limit=".len()..].parse() { + Ok(n) => limit = Some(n), + Err(_) => { + eprintln!("--limit requires a number\n\n{}", USAGE); + return Some(2); + } + } + } + other if other.starts_with('-') && terms.is_empty() => { + // Unknown flags without a query fall through to the GUI + // (they may be eframe/winit flags). + return None; + } + other => terms.push(other.to_string()), + } + } + + if terms.is_empty() { + return None; + } + Some(run_query(&terms.join(" "), fuzzy, limit, long)) +} + +fn run_query(query: &str, fuzzy: bool, limit: Option, long: bool) -> i32 { + let config = match Config::load() { + Ok(c) => c, + Err(e) => { + eprintln!("config: {}", e); + return 2; + } + }; + let db_path = config.resolved_database_path(); + // Read-write purely so SQLite may create the WAL shared-memory file + // when no other process has the index open; nothing is written. + let conn = match db::open_existing(&db_path.to_string_lossy(), true) { + Ok(c) => c, + Err(e) => { + eprintln!( + "No usable index at {}; run the GUI once to build it.\n({})", + db_path.display(), + e + ); + return 2; + } + }; + + let split = match split_for_cascade(query) { + Ok(s) => s, + Err(e) => { + eprintln!("query: {}", e); + return 2; + } + }; + + if fuzzy { + if let Some(warning) = config.search.fuzzy_edits_warning() { + eprintln!("warning: {}", warning); + } + } + let options = SearchOptions { + fuzzy, + fuzzy_max_edits: config.search.fuzzy_max_edits, + limit: limit.unwrap_or(config.search.display_limit), + batch: config.search.results_per_page.max(1), + session_ignores: Vec::new(), + }; + let latest = AtomicU64::new(1); + let mut hits: Vec = Vec::new(); + let outcome = cascade::run(&conn, &split, &options, 1, &latest, &mut |batch| { + hits.extend(batch) + }); + + match outcome { + Ok(Some(outcome)) => { + let color = long && std::io::stdout().is_terminal() && enable_vt(); + for hit in &hits { + if long { + println!( + "{:6.3} {:>9} {} {}", + hit.rank, + human_size(hit.size), + fmt_mtime(hit.mtime), + hit.path + ); + if let Some(snip) = &hit.snippet { + println!(" {}", render_snippet(snip, color)); + } + } else { + println!("{}", hit.path); + } + } + if outcome.limited { + eprintln!("(truncated at {} results; raise with --limit)", hits.len()); + } + 0 + } + Ok(None) => 0, // unreachable: nothing cancels a CLI search + Err(e) => { + eprintln!("search: {}", e); + 2 + } + } +} + +/// Whether ANSI escapes will actually render. +/// +/// Always true where the terminal is ANSI by nature. On Windows the console +/// only interprets escapes once `ENABLE_VIRTUAL_TERMINAL_PROCESSING` is set: +/// Windows Terminal and Windows 11 have it already, older conhost needs it +/// turned on, and anything that refuses gets plain text rather than a screen +/// full of `\x1b[1m`. +#[cfg(not(windows))] +fn enable_vt() -> bool { + true +} + +#[cfg(windows)] +fn enable_vt() -> bool { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::{ + GetConsoleMode, GetStdHandle, SetConsoleMode, ENABLE_VIRTUAL_TERMINAL_PROCESSING, + STD_OUTPUT_HANDLE, + }; + + unsafe { + let handle = GetStdHandle(STD_OUTPUT_HANDLE); + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return false; + } + let mut mode = 0; + if GetConsoleMode(handle, &mut mode) == 0 { + return false; + } + mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 + || SetConsoleMode(handle, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING) != 0 + } +} + +/// One-line snippet with matches emphasized (ANSI bold on TTYs). +fn render_snippet(snip: &quicksearch_core::snippet::Snippet, color: bool) -> String { + let mut out = String::new(); + if snip.truncated_start { + out.push('…'); + } + let mut cursor = 0; + for &(start, end) in &snip.ranges { + out.push_str(&snip.window[cursor..start]); + if color { + out.push_str("\x1b[1m"); + out.push_str(&snip.window[start..end]); + out.push_str("\x1b[0m"); + } else { + out.push_str(&snip.window[start..end]); + } + cursor = end; + } + out.push_str(&snip.window[cursor..]); + if snip.truncated_end { + out.push('…'); + } + out.replace(['\n', '\r'], " ") +} diff --git a/crates/quicksearch-gui/src/cli_main.rs b/crates/quicksearch-gui/src/cli_main.rs new file mode 100644 index 0000000..778d833 --- /dev/null +++ b/crates/quicksearch-gui/src/cli_main.rs @@ -0,0 +1,21 @@ +//! `quicksearch-cli ` — terminal search, and nothing else. +//! +//! A console-subsystem binary, so redirection, pipes, exit codes, and the +//! shell waiting for the process all behave normally. `src/cli.rs` and +//! `src/format.rs` are shared with the GUI binary by compiling them into both; +//! neither touches egui, so there is nothing to split out into a library. + +mod cli; +// The GUI uses more of this module than the CLI does. +#[allow(dead_code)] +mod format; + +fn main() { + // `maybe_run_cli` returns `None` for "no query given", which the combined + // binary treats as "open the GUI". This one has no GUI to fall back to. + let code = cli::maybe_run_cli().unwrap_or_else(|| { + eprintln!("{}", cli::USAGE); + 2 + }); + std::process::exit(code); +} diff --git a/crates/quicksearch-gui/src/duplicates_tab.rs b/crates/quicksearch-gui/src/duplicates_tab.rs new file mode 100644 index 0000000..bbefa34 --- /dev/null +++ b/crates/quicksearch-gui/src/duplicates_tab.rs @@ -0,0 +1,111 @@ +//! The Duplicates tab: groups of files sharing a content hash. + +use quicksearch_core::search::DuplicateGroup; + +use crate::format::{group_thousands, human_size}; +use crate::platform; + +pub enum DupState { + NotLoaded, + Loading, + Loaded(Vec), + Error(String), +} + +pub struct DuplicatesTab { + pub state: DupState, +} + +/// What the tab asks the app to do after this frame. +#[derive(Default)] +pub struct DuplicatesActions { + pub refresh: bool, +} + +impl DuplicatesTab { + pub fn new() -> DuplicatesTab { + DuplicatesTab { + state: DupState::NotLoaded, + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> DuplicatesActions { + let mut actions = DuplicatesActions::default(); + + ui.horizontal(|ui| { + let loading = matches!(self.state, DupState::Loading); + if ui.add_enabled(!loading, egui::Button::new("Refresh")).clicked() { + actions.refresh = true; + } + if loading { + ui.add(egui::Spinner::new().size(16.0)); + ui.label("Scanning for duplicates…"); + } + }); + ui.separator(); + + match &self.state { + DupState::NotLoaded => { + ui.label( + egui::RichText::new("Press Refresh to scan the index for duplicate files.") + .weak(), + ); + } + DupState::Loading => {} + DupState::Error(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + DupState::Loaded(groups) => { + if groups.is_empty() { + ui.label("No duplicate files found."); + return actions; + } + if groups.len() == 500 { + ui.label( + egui::RichText::new("Showing the 500 largest groups.").small().weak(), + ); + } + egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { + for (i, group) in groups.iter().enumerate() { + let name = group + .members + .first() + .map(|m| m.1.as_str()) + .unwrap_or("(unknown)"); + let title = format!( + "{} × {}: {} reclaimable ({} total)", + group_thousands(group.count as u64), + name, + human_size(group.redundant_size.max(0) as u64), + human_size(group.total_size.max(0) as u64), + ); + egui::CollapsingHeader::new(title).id_salt(i).show(ui, |ui| { + for (_, _, path, size, _) in &group.members { + ui.horizontal(|ui| { + ui.label(human_size(*size)); + let response = ui + .add(egui::Label::new(egui::RichText::new(path).monospace()) + .sense(egui::Sense::click())); + if response.double_clicked() { + platform::open_file(path); + } + response.context_menu(|ui| { + if ui.button("Open").clicked() { + platform::open_file(path); + ui.close(); + } + if ui.button("Open containing folder").clicked() { + platform::reveal_in_folder(path); + ui.close(); + } + }); + }); + } + }); + } + }); + } + } + actions + } +} diff --git a/crates/quicksearch-gui/src/format.rs b/crates/quicksearch-gui/src/format.rs new file mode 100644 index 0000000..468ce1e --- /dev/null +++ b/crates/quicksearch-gui/src/format.rs @@ -0,0 +1,202 @@ +//! Small display formatters shared across tabs. + +/// Human-readable byte size: `999 B`, `1.2 KB`, `4.7 MB`, `1.3 GB`. +pub fn human_size(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1000.0 && unit < UNITS.len() - 1 { + value /= 1000.0; + unit += 1; + } + if unit == 0 { + format!("{} B", bytes) + } else { + format!("{:.1} {}", value, UNITS[unit]) + } +} + +/// `YYYY-MM-DD HH:MM` in local time; raw seconds if out of range. +pub fn fmt_mtime(unix_secs: i64) -> String { + use chrono::TimeZone; + match chrono::Local.timestamp_opt(unix_secs, 0) { + chrono::LocalResult::Single(dt) => dt.format("%Y-%m-%d %H:%M").to_string(), + _ => unix_secs.to_string(), + } +} + +/// Relative time for recent events, absolute for old ones: "just now", +/// "5 min ago", "3 h ago", else `YYYY-MM-DD HH:MM`. Gives instant +/// feedback that an action (like a fast index run) actually happened. +pub fn fmt_ago(unix_secs: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let age = now.saturating_sub(unix_secs); + if age < 60 { + "just now".to_string() + } else if age < 3600 { + format!("{} min ago", age / 60) + } else if age < 86_400 { + format!("{} h ago", age / 3600) + } else { + fmt_mtime(unix_secs as i64) + } +} + +/// A configured interval as a phrase to drop after "every": `90 min`, +/// `24 h`, `3 days`. Used where the periodic reindex is the only thing +/// refreshing the index, so the user can judge how stale it may get. +pub fn fmt_interval(minutes: u64) -> String { + if minutes == 0 { + // The scheduler treats 0 as always-due. + return "run".to_string(); + } + if minutes < 60 { + return format!("{} min", minutes); + } + if minutes.is_multiple_of(1440) { + let days = minutes / 1440; + return if days == 1 { + // "24 h" reads better than "1 day" for the shipped default. + "24 h".to_string() + } else { + format!("{} days", days) + }; + } + if minutes.is_multiple_of(60) { + return format!("{} h", minutes / 60); + } + format!("{} h {} min", minutes / 60, minutes % 60) +} + +/// Group thousands for counts: `1,234,567`. +pub fn group_thousands(n: u64) -> String { + let digits = n.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + +/// Files/sec display. Never renders a nonzero rate as "0.0": slow rates +/// switch to a per-minute figure. +pub fn fmt_rate(files_per_sec: f64) -> String { + if files_per_sec <= 0.0 { + "0 files/s".to_string() + } else if files_per_sec >= 10.0 { + format!("{:.0} files/s", files_per_sec) + } else if files_per_sec >= 1.0 { + format!("{:.1} files/s", files_per_sec) + } else { + format!("{:.0} files/min", (files_per_sec * 60.0).max(1.0)) + } +} + +/// Search duration: milliseconds under a second, seconds above. +pub fn fmt_elapsed(d: std::time::Duration) -> String { + let ms = d.as_millis(); + if ms >= 1000 { + format!("{:.1} s", d.as_secs_f64()) + } else { + format!("{} ms", ms) + } +} + +/// Middle-truncate a path to at most `max_chars` characters. +pub fn middle_truncate(s: &str, max_chars: usize) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() <= max_chars || max_chars < 5 { + return s.to_string(); + } + let keep = max_chars - 1; + let head = keep / 2; + let tail = keep - head; + let mut out: String = chars[..head].iter().collect(); + out.push('…'); + out.extend(&chars[chars.len() - tail..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sizes() { + assert_eq!(human_size(0), "0 B"); + assert_eq!(human_size(999), "999 B"); + assert_eq!(human_size(1200), "1.2 KB"); + assert_eq!(human_size(4_700_000), "4.7 MB"); + assert_eq!(human_size(1_300_000_000), "1.3 GB"); + } + + #[test] + fn intervals() { + assert_eq!(fmt_interval(0), "run"); + assert_eq!(fmt_interval(1), "1 min"); + assert_eq!(fmt_interval(59), "59 min"); + assert_eq!(fmt_interval(60), "1 h"); + assert_eq!(fmt_interval(90), "1 h 30 min"); + assert_eq!(fmt_interval(120), "2 h"); + assert_eq!(fmt_interval(1440), "24 h", "the shipped default"); + assert_eq!(fmt_interval(2880), "2 days"); + assert_eq!(fmt_interval(10_080), "7 days"); + } + + #[test] + fn thousands() { + assert_eq!(group_thousands(0), "0"); + assert_eq!(group_thousands(999), "999"); + assert_eq!(group_thousands(1000), "1,000"); + assert_eq!(group_thousands(1_234_567), "1,234,567"); + } + + #[test] + fn rates_never_show_zero_for_nonzero() { + assert_eq!(fmt_rate(0.0), "0 files/s"); + assert_eq!(fmt_rate(2543.0), "2543 files/s"); + // Not 3.14: clippy reads that as a botched `PI` and denies it. + assert_eq!(fmt_rate(3.12), "3.1 files/s"); + assert_eq!(fmt_rate(0.4), "24 files/min"); + assert_eq!(fmt_rate(0.001), "1 files/min", "floor at 1/min, never 0.0"); + } + + #[test] + fn elapsed_units() { + use std::time::Duration; + assert_eq!(fmt_elapsed(Duration::from_millis(0)), "0 ms"); + assert_eq!(fmt_elapsed(Duration::from_millis(7)), "7 ms"); + assert_eq!(fmt_elapsed(Duration::from_millis(999)), "999 ms"); + assert_eq!(fmt_elapsed(Duration::from_millis(1000)), "1.0 s"); + assert_eq!(fmt_elapsed(Duration::from_millis(2340)), "2.3 s"); + } + + #[test] + fn ago_buckets() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + assert_eq!(fmt_ago(now), "just now"); + assert_eq!(fmt_ago(now - 59), "just now"); + assert_eq!(fmt_ago(now - 120), "2 min ago"); + assert_eq!(fmt_ago(now - 7200), "2 h ago"); + assert!(fmt_ago(now - 200_000).contains('-'), "old = absolute date"); + } + + #[test] + fn truncation() { + assert_eq!(middle_truncate("short", 20), "short"); + let t = middle_truncate("/very/long/path/to/some/file.txt", 15); + assert!(t.chars().count() <= 15); + assert!(t.contains('…')); + assert!(t.starts_with("/very")); + assert!(t.ends_with("e.txt")); + } +} diff --git a/crates/quicksearch-gui/src/frontend.rs b/crates/quicksearch-gui/src/frontend.rs deleted file mode 100644 index b3c0394..0000000 --- a/crates/quicksearch-gui/src/frontend.rs +++ /dev/null @@ -1,385 +0,0 @@ -#![allow(non_snake_case)] - -use std::sync::Arc; -use std::collections::VecDeque; -use std::time::Instant; -use dioxus::prelude::*; -use quicksearch_core::indexing::{IndexingService, IndexingStatus}; -use quicksearch_core::config::Config; - -#[derive(Debug, Clone)] -struct SpeedDataPoint { - timestamp: Instant, - files_processed: usize, -} - -struct SpeedTracker { - data_points: VecDeque, -} - -impl SpeedTracker { - fn new() -> Self { - Self { - data_points: VecDeque::new(), - } - } - - fn add_data_point(&mut self, files_processed: usize) { - let now = Instant::now(); - self.data_points.push_back(SpeedDataPoint { - timestamp: now, - files_processed, - }); - - // Prune data points older than 1 second - while let Some(front) = self.data_points.front() { - if now.duration_since(front.timestamp).as_secs_f64() > 1.0 { - self.data_points.pop_front(); - } else { - break; - } - } - } - - fn calculate_files_per_second(&self) -> Option { - if self.data_points.len() < 2 { - return None; - } - - let newest = self.data_points.back()?; - let oldest = self.data_points.front()?; - - let time_span = newest.timestamp.duration_since(oldest.timestamp).as_secs_f64(); - if time_span < 0.1 { // Avoid division by very small numbers - return None; - } - - let files_diff = newest.files_processed.saturating_sub(oldest.files_processed); - Some(files_diff as f64 / time_span) - } -} - -#[derive(Props, Clone)] -pub struct AppProps { - pub indexing_service: Arc, - pub config: Config, -} - -impl PartialEq for AppProps { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.indexing_service, &other.indexing_service) - && self.config.paths.indexing_paths == other.config.paths.indexing_paths - && self.config.paths.database_path == other.config.paths.database_path - } -} - -pub fn App(props: AppProps) -> Element { - // Multi-root support: the GUI's single line edit holds one path per line. - // Empty lines are ignored. Core still de-duplicates and handles nested roots. - let mut indexing_path = use_signal(|| props.config.paths.indexing_paths.join("\n")); - let mut db_path = use_signal(|| props.config.paths.database_path.clone()); - let mut status_text = use_signal(|| "Idle".to_string()); - let mut show_config_dialog = use_signal(|| false); - let mut config_changes = use_signal(|| Vec::::new()); - let speed_tracker = use_signal(|| SpeedTracker::new()); - - - let indexing_service_for_start = props.indexing_service.clone(); - let indexing_service_for_start_dialog = props.indexing_service.clone(); - let indexing_service_for_stop = props.indexing_service.clone(); - let indexing_service_for_timer = props.indexing_service.clone(); - let config_for_start = props.config.clone(); - let config_for_dialog = props.config.clone(); - - - // Automatic status updates every second - { - let mut status_text_clone = status_text.clone(); - let mut speed_tracker_clone = speed_tracker.clone(); - let service_clone = indexing_service_for_timer.clone(); - use_future(move || { - let service = service_clone.clone(); - async move { - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; - - let status = service.get_status(); - let status_str = match status { - IndexingStatus::Idle => { - // Reset speed tracker when idle - speed_tracker_clone.set(SpeedTracker::new()); - "Idle".to_string() - }, - IndexingStatus::CountingFiles { - current_file, - start_time, - .. - } => { - let elapsed = start_time.elapsed(); - let current_file_display = current_file - .as_ref() - .map(|f| format!("{}", f)) - .unwrap_or_else(|| "...".to_string()); - format!( - "Phase 0 - Counting paths (shell) - {:.1}s elapsed\n{}", - elapsed.as_secs_f64(), - current_file_display - ) - } - IndexingStatus::RunningFileIndex { files_processed, total_files, current_file, start_time } => { - // Add data point to speed tracker - speed_tracker_clone.with_mut(|tracker| { - tracker.add_data_point(files_processed); - }); - - let elapsed = start_time.elapsed(); - let current_file_display = current_file - .as_ref() - .map(|f| format!("Current: {}", f)) - .unwrap_or_default(); - - // Calculate speed - let speed_display = speed_tracker_clone.with(|tracker| { - tracker.calculate_files_per_second() - .map(|fps| format!(" - {:.1} files/sec", fps)) - .unwrap_or_default() - }); - - if let Some(total) = total_files { - let percentage = if total > 0 { - (files_processed as f64 / total as f64 * 100.0) as u32 - } else { 0 }; - format!( - "Phase 1 - File Index: {}/{} files ({}%) - {:.1}s elapsed{}\n{}", - files_processed, - total, - percentage, - elapsed.as_secs_f64(), - speed_display, - current_file_display - ) - } else { - format!( - "Phase 1 - File Index: {} files processed - {:.1}s elapsed{}\n{}", - files_processed, - elapsed.as_secs_f64(), - speed_display, - current_file_display - ) - } - } - IndexingStatus::RunningTextIndex { files_processed, current_file, start_time } => { - // Add data point to speed tracker - speed_tracker_clone.with_mut(|tracker| { - tracker.add_data_point(files_processed); - }); - - let elapsed = start_time.elapsed(); - let current_file_display = current_file - .as_ref() - .map(|f| format!("Current: {}", f)) - .unwrap_or_default(); - - // Calculate speed - let speed_display = speed_tracker_clone.with(|tracker| { - tracker.calculate_files_per_second() - .map(|fps| format!(" - {:.1} files/sec", fps)) - .unwrap_or_default() - }); - format!( - "Phase 2 - Text Index: {} files processed - {:.1}s elapsed{}\n{}", - files_processed, - elapsed.as_secs_f64(), - speed_display, - current_file_display - ) - } - IndexingStatus::Stopping => "Indexing Stopped".to_string(), - IndexingStatus::Error(ref e) => format!("Error: {}", e), - }; - status_text_clone.set(status_str); - } - } - }); - } - - rsx! { - div { - class: "app-container", - - div { - class: "app-header", - h1 { "QuickSearch File Indexer" } - } - - div { - class: "app-content", - - div { - class: "section", - h2 { "Indexing Controls" } - - div { - class: "form-group", - label { "Paths to index (one per line):" } - textarea { - class: "form-control", - rows: "3", - value: "{indexing_path}", - oninput: move |evt| indexing_path.set(evt.value()) - } - } - - div { - class: "form-group", - label { "Database path:" } - input { - class: "form-control", - r#type: "text", - value: "{db_path}", - oninput: move |evt| db_path.set(evt.value()) - } - } - - div { - class: "form-group", - button { - class: "btn btn-primary", - onclick: move |_| { - let service = indexing_service_for_start.clone(); - let config = config_for_start.clone(); - let path_text = indexing_path().clone(); - let paths: Vec = path_text - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect(); - let db = db_path().clone(); - - if paths.is_empty() { - status_text.set("Enter at least one path to index (one per line).".to_string()); - } else { - // The config_validation key still stores a single joined string. - let joined = paths.join("\n"); - match service.check_config_validation(&db, &config, &joined) { - Ok(Some(changes)) => { - config_changes.set(changes); - show_config_dialog.set(true); - } - Ok(None) => { - let _ = service.start_indexing(paths, db, config); - } - Err(e) => { - status_text.set(format!("Configuration validation error: {}", e)); - } - } - } - }, - "Start Indexing" - } - button { - class: "btn btn-danger", - onclick: move |_| { - let _ = indexing_service_for_stop.stop_indexing(); - }, - "Stop Indexing" - } - } - } - - div { - class: "section", - h2 { "Status" } - pre { - class: "status-display", - "{status_text}" - } - } - - crate::search::Search { - indexing_service: props.indexing_service.clone(), - db_path: db_path().clone() - } - - } // Close app-content - } - - // Configuration validation dialog - if show_config_dialog() { - div { - class: "modal-backdrop", - div { - class: "modal-dialog", - h3 { - style: "margin-top: 0; color: #d32f2f;", - "⚠️ Configuration Changes Detected" - } - p { - style: "margin: 15px 0;", - "The following configuration changes require deleting and rebuilding the search index:" - } - ul { - style: "margin: 15px 0; padding-left: 20px;", - for change in config_changes().iter() { - li { - style: "margin: 5px 0; font-family: monospace; background-color: #f5f5f5; padding: 5px; border-radius: 3px;", - "{change}" - } - } - } - p { - style: "margin: 15px 0; font-weight: bold;", - "This will delete the existing index and rebuild it from scratch." - } - div { - style: "display: flex; gap: 10px; margin-top: 20px;", - button { - style: "padding: 10px 20px; background-color: #d32f2f; color: white; border: none; border-radius: 5px; cursor: pointer;", - onclick: move |_| { - let service = indexing_service_for_start_dialog.clone(); - let config = config_for_dialog.clone(); - let path_text = indexing_path().clone(); - let paths: Vec = path_text - .lines() - .map(|l| l.trim().to_string()) - .filter(|l| !l.is_empty()) - .collect(); - let db = db_path().clone(); - - show_config_dialog.set(false); - status_text.set("Stopping indexing and deleting database...".to_string()); - - // Delete database file and restart indexing - let service_clone = service.clone(); - let paths_clone = paths.clone(); - let db_clone = db.clone(); - let config_clone = config.clone(); - let mut status_clone = status_text.clone(); - - spawn(async move { - match service_clone.delete_index_for_rebuild(&db_clone) { - Ok(()) => { - status_clone.set("Database deleted. Starting fresh indexing...".to_string()); - let _ = service_clone.start_indexing(paths_clone, db_clone, config_clone); - } - Err(e) => { - status_clone.set(format!("Error deleting database: {}", e)); - } - } - }); - }, - "Yes, Rebuild Index" - } - button { - style: "padding: 10px 20px; background-color: #666; color: white; border: none; border-radius: 5px; cursor: pointer;", - onclick: move |_| { - show_config_dialog.set(false); - }, - "Cancel" - } - } - } - } - } - } -} \ No newline at end of file diff --git a/crates/quicksearch-gui/src/logs_tab.rs b/crates/quicksearch-gui/src/logs_tab.rs new file mode 100644 index 0000000..04fdc30 --- /dev/null +++ b/crates/quicksearch-gui/src/logs_tab.rs @@ -0,0 +1,255 @@ +//! The Logs tab: what the terminal would have shown. +//! +//! Everything here comes from [`quicksearch_core::log`], which background +//! threads write through instead of printing. Launched from a desktop +//! launcher there is no terminal to read, and this is the only place a +//! "cannot read that folder" warning is visible. + +use quicksearch_core::log::{self, Level, LogLine}; + +use crate::format::group_thousands; + +/// How often to repaint while the tab is open. Log lines arrive on indexer +/// and watcher threads, which have no reason to wake the UI, so an idle +/// window would otherwise sit on a stale list until the mouse moved. +const REFRESH_MS: u64 = 500; + +pub struct LogsTab { + /// Copy of the ring, refreshed only when the recorded count moves — + /// cloning a few thousand lines every frame would be silly. + lines: Vec, + /// [`log::recorded`] as of the last refresh. + seen: u64, + dropped: u64, + filter: String, + warnings_only: bool, + /// Keep the newest line in view. Scrolling up releases the view anyway + /// (egui unsticks a scroll area the user moves, and re-sticks it when + /// they return to the bottom); unticking this stops it following at all. + follow: bool, +} + +impl LogsTab { + pub fn new() -> LogsTab { + LogsTab { + lines: Vec::new(), + seen: 0, + dropped: 0, + filter: String::new(), + warnings_only: false, + follow: true, + } + } + + fn refresh(&mut self) { + self.lines = log::snapshot(); + self.seen = log::recorded(); + self.dropped = log::dropped(); + } + + pub fn ui(&mut self, ui: &mut egui::Ui) { + if log::recorded() != self.seen { + self.refresh(); + } + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(REFRESH_MS)); + + // Indices rather than references: the control row below takes the + // filter and follow flags mutably, and a borrow of `self.lines` + // held across it would conflict. One frame of lag after a + // keystroke, which repaints immediately anyway. + let needle = self.filter.to_lowercase(); + let shown: Vec = self + .lines + .iter() + .enumerate() + .filter(|(_, l)| keep(l, &needle, self.warnings_only)) + .map(|(i, _)| i) + .collect(); + + let mut cleared = false; + ui.horizontal(|ui| { + ui.checkbox(&mut self.follow, "Follow") + .on_hover_text("Scroll to the newest line as it arrives"); + ui.checkbox(&mut self.warnings_only, "Warnings only"); + ui.add( + egui::TextEdit::singleline(&mut self.filter) + .desired_width(200.0) + .hint_text("Filter"), + ); + if ui + .add_enabled(!shown.is_empty(), egui::Button::new("Copy")) + .on_hover_text("Copy the lines shown below to the clipboard") + .clicked() + { + let joined = shown + .iter() + .map(|&i| self.lines[i].text.as_str()) + .collect::>() + .join("\n"); + ui.ctx().copy_text(joined); + } + if ui + .add_enabled(!self.lines.is_empty(), egui::Button::new("Clear")) + .clicked() + { + log::clear(); + cleared = true; + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let count = if shown.len() == self.lines.len() { + format!("{} lines", group_thousands(self.lines.len() as u64)) + } else { + format!( + "{} of {} lines", + group_thousands(shown.len() as u64), + group_thousands(self.lines.len() as u64) + ) + }; + ui.label(egui::RichText::new(count).small().weak()); + }); + }); + if cleared { + // `shown` indexes lines that no longer exist. + self.refresh(); + return; + } + if self.dropped > 0 { + ui.label( + egui::RichText::new(format!( + "{} earlier lines were dropped; the newest {} are kept.", + group_thousands(self.dropped), + group_thousands(log::CAPACITY as u64), + )) + .small() + .weak(), + ); + } + ui.separator(); + + if self.lines.is_empty() { + ui.label( + egui::RichText::new( + "Nothing logged yet. Warnings from indexing, watching folders and \ + opening files appear here — the same lines the terminal would show.", + ) + .weak(), + ); + return; + } + if shown.is_empty() { + ui.label(egui::RichText::new("No lines match the filter.").weak()); + return; + } + + // Long paths extend into a horizontal scroll rather than wrapping: + // `show_rows` only draws the visible slice, and that costs nothing + // only while every row is exactly one line tall. + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + let row_height = ui.text_style_height(&egui::TextStyle::Monospace); + egui::ScrollArea::both() + .auto_shrink([false; 2]) + .stick_to_bottom(self.follow) + .show_rows(ui, row_height, shown.len(), |ui, range| { + for &i in &shown[range] { + let line = &self.lines[i]; + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(fmt_clock(line.at)) + .monospace() + .weak(), + ); + let text = egui::RichText::new(&line.text).monospace(); + match line.level { + Level::Warn => { + ui.colored_label(ui.visuals().warn_fg_color, text); + } + Level::Info => { + ui.label(text); + } + } + }); + } + }); + } +} + +/// Whether a line survives the tab's two filters. `needle` is expected +/// already lowercased — it is the same for every line, so folding it once +/// per frame beats folding it per line. +fn keep(line: &LogLine, needle: &str, warnings_only: bool) -> bool { + if warnings_only && line.level != Level::Warn { + return false; + } + needle.is_empty() || line.text.to_lowercase().contains(needle) +} + +/// `HH:MM:SS` local time. The date is deliberately absent: these lines are +/// read while something is going wrong now, and a full stamp on every row +/// would crowd out the message. +fn fmt_clock(unix_secs: u64) -> String { + use chrono::TimeZone; + // Saturating rather than `as`: that cast wraps a huge value into a + // negative one, which is a perfectly valid 1969 timestamp and would + // render as a plausible time instead of falling back. + let secs = i64::try_from(unix_secs).unwrap_or(i64::MAX); + match chrono::Local.timestamp_opt(secs, 0) { + chrono::LocalResult::Single(dt) => dt.format("%H:%M:%S").to_string(), + _ => "--:--:--".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{fmt_clock, keep}; + use quicksearch_core::log::{Level, LogLine}; + + fn line(level: Level, text: &str) -> LogLine { + LogLine { + at: 1_700_000_000, + level, + text: text.to_string(), + } + } + + #[test] + fn an_empty_filter_keeps_everything() { + assert!(keep(&line(Level::Info, "anything"), "", false)); + assert!(keep(&line(Level::Warn, "anything"), "", false)); + } + + #[test] + fn the_filter_ignores_case_on_both_sides() { + let l = line(Level::Warn, "Warning: cannot read /Home/Photos"); + assert!(keep(&l, "photos", false), "needle case must not matter"); + assert!(keep(&l, "cannot read", false), "nor the line's"); + assert!(!keep(&l, "videos", false)); + } + + #[test] + fn warnings_only_hides_informational_lines() { + assert!(!keep(&line(Level::Info, "shutting down"), "", true)); + assert!(keep(&line(Level::Warn, "cannot read"), "", true)); + } + + /// Both filters apply, not either. + #[test] + fn the_two_filters_compose() { + assert!(!keep(&line(Level::Info, "cannot read"), "cannot", true)); + assert!(!keep(&line(Level::Warn, "cannot read"), "missing", true)); + assert!(keep(&line(Level::Warn, "cannot read"), "cannot", true)); + } + + #[test] + fn a_clock_stamp_is_fixed_width() { + assert_eq!(fmt_clock(0).len(), 8, "epoch renders as a time, not a date"); + assert_eq!(fmt_clock(1_700_000_000).len(), 8); + } + + /// Beyond what a local calendar can represent, the row still lines up. + #[test] + fn an_out_of_range_stamp_falls_back() { + assert_eq!(fmt_clock(u64::MAX), "--:--:--"); + } +} diff --git a/crates/quicksearch-gui/src/main.rs b/crates/quicksearch-gui/src/main.rs index 4a42a9c..173f89c 100644 --- a/crates/quicksearch-gui/src/main.rs +++ b/crates/quicksearch-gui/src/main.rs @@ -1,54 +1,95 @@ -use std::sync::{Arc, OnceLock}; -use dioxus::prelude::*; -use quicksearch_core::{config, indexing, shutdown}; -mod frontend; -mod search; +//! QuickSearch binary: `quicksearch ` searches from the terminal; +//! without a query it opens the egui desktop app. +//! +//! On Windows this is the GUI only, built as a window-subsystem app so no +//! console flashes behind it. Terminal search there is `quicksearch-cli`, +//! which is a console app and so keeps working pipes, exit codes, and a shell +//! that waits for it. A query passed here still does something useful: it +//! seeds the search box. +#![cfg_attr(windows, windows_subsystem = "windows")] -static INDEXING_SERVICE: OnceLock> = OnceLock::new(); +mod app; +mod backend; +#[cfg(not(windows))] +mod cli; +mod duplicates_tab; +mod format; +mod logs_tab; +mod manage_tab; +mod options; +mod platform; +mod query_highlight; +mod search_tab; +mod tracker; + +use quicksearch_core::config::Config; + +/// The window icon, shown in the titlebar, taskbar and alt-tab switcher. +/// +/// X11 takes these pixels directly via `_NET_WM_ICON`. Wayland ignores them and +/// instead looks up the app id in `/usr/share/applications/`, so the id below has +/// to match the installed `quicksearch.desktop` for the icon to appear there. +fn app_icon() -> egui::IconData { + eframe::icon_data::from_png_bytes(include_bytes!("../assets/icons/quicksearch-256.png")) + .expect("bundled icon is a valid PNG") +} + +/// Leftover positional arguments, joined — used to seed the search box. +/// +/// Flags are dropped rather than parsed: eframe and winit take some of their +/// own, and a stray `--foo` should not end up in the query. +fn seed_query() -> Option { + let terms: Vec = std::env::args() + .skip(1) + .filter(|a| !a.starts_with('-')) + .collect(); + if terms.is_empty() { + None + } else { + Some(terms.join(" ")) + } +} fn main() { - let indexing_service = Arc::new(indexing::IndexingService::new()); - INDEXING_SERVICE - .set(indexing_service.clone()) - .expect("Failed to set global indexing service"); + // Must come first: anything below may print, and printing without a + // stdio handle panics rather than failing quietly. + #[cfg(windows)] + platform::redirect_null_stdio(); - if let Err(e) = shutdown::install_signal_handler(indexing_service.clone()) { - eprintln!("Warning: failed to install signal handler: {}", e); + #[cfg(not(windows))] + if let Some(code) = cli::maybe_run_cli() { + std::process::exit(code); } - LaunchBuilder::desktop() - .with_cfg( - dioxus_desktop::Config::new() - .with_custom_head(format!("", include_str!("../assets/styles.css"))) - .with_window(dioxus_desktop::WindowBuilder::new() - .with_title("QuickSearch - File Indexer & Search") - .with_resizable(true) - .with_inner_size(dioxus_desktop::LogicalSize::new(1000.0, 700.0)) - ) - ) - .launch(app); -} + // A broken config file should never keep the window from opening — + // surface the error in-app and run on defaults. + let (config, config_error) = match Config::load() { + Ok(c) => (c, None), + Err(e) => (Config::default(), Some(e)), + }; + let initial_query = seed_query(); - -fn app() -> Element { - let cfg = match config::Config::load() { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to load config: {}", e); - return rsx! { div { "Failed to load configuration" } }; - } + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("QuickSearch") + .with_app_id("quicksearch") + .with_icon(app_icon()) + .with_inner_size([1000.0, 700.0]) + .with_min_inner_size([640.0, 400.0]), + ..Default::default() }; - let indexing_service = INDEXING_SERVICE - .get() - .expect("Indexing service not initialized") - .clone(); - - rsx! { - frontend::App { - indexing_service: indexing_service, - config: cfg - } + let result = eframe::run_native( + "QuickSearch", + native_options, + Box::new(move |cc| { + app::QuickSearchApp::new(cc, config, config_error, initial_query) + .map(|app| Box::new(app) as Box) + .map_err(|e| e.into()) + }), + ); + if let Err(e) = result { + eprintln!("failed to start GUI: {}", e); + std::process::exit(1); } } - diff --git a/crates/quicksearch-gui/src/manage_tab.rs b/crates/quicksearch-gui/src/manage_tab.rs new file mode 100644 index 0000000..015c682 --- /dev/null +++ b/crates/quicksearch-gui/src/manage_tab.rs @@ -0,0 +1,447 @@ +//! The Manage Index tab: detailed status, mode controls, indexed roots, +//! and the content/ignore filter editors. + +use quicksearch_core::config::Config; +use quicksearch_core::coordinator::{IndexMode, IndexerState, WatcherStatus}; +use quicksearch_core::indexing::{IndexingStatus, RootPhase, RootProgress}; + +use crate::format::{fmt_interval, fmt_rate, group_thousands, middle_truncate}; +use crate::options::{config_editor_ui, Section}; +use crate::tracker::SpeedTracker; + +/// What the tab asks the app to do after this frame. +#[derive(Default)] +pub struct ManageActions { + pub start_now: bool, + pub stop: bool, + pub auto: bool, + /// Ask the app to confirm and delete the index. + pub clear_index: bool, + /// A full edited config to apply (roots / filters / indexing knobs). + pub apply_config: Option, +} + +pub struct ManageTab { + pub speed: SpeedTracker, + /// Multiline editors, one entry per line; synced from config on tab + /// entry and parsed back on Apply. + ext_filter_text: String, + ignore_filter_text: String, + new_root: String, + /// Inline error from a rejected root add (nested/duplicate). + root_error: Option, + editors_synced: bool, + /// Draft of the indexing/processing knobs edited in-place. + draft: Option, +} + +impl ManageTab { + pub fn new() -> ManageTab { + ManageTab { + speed: SpeedTracker::new(), + ext_filter_text: String::new(), + ignore_filter_text: String::new(), + new_root: String::new(), + root_error: None, + editors_synced: false, + draft: None, + } + } + + /// Feed the tracker from the polled status (called every frame, on + /// every tab, so the status bar rate stays live). + pub fn observe(&mut self, status: &IndexingStatus) { + match status { + IndexingStatus::Running { roots, .. } => { + // Monotonic within a run: walks and extractions only grow. + let total: usize = roots.iter().map(|r| r.walked + r.extracted).sum(); + self.speed.record(total); + } + IndexingStatus::Idle | IndexingStatus::Error(_) => self.speed.reset(), + _ => {} + } + } + + fn sync_editors(&mut self, config: &Config) { + if !self.editors_synced { + self.ext_filter_text = config.indexing.content_extensions.join("\n"); + self.ignore_filter_text = config.indexing.ignore_patterns.join("\n"); + self.draft = Some(config.clone()); + self.editors_synced = true; + } + } + + /// Force a re-sync next frame (config changed elsewhere). + pub fn invalidate_editors(&mut self) { + self.editors_synced = false; + } + + pub fn ui( + &mut self, + ui: &mut egui::Ui, + state: &IndexerState, + config: &Config, + ) -> ManageActions { + let mut actions = ManageActions::default(); + self.sync_editors(config); + + egui::ScrollArea::vertical().auto_shrink([false; 2]).show(ui, |ui| { + // --- Status --------------------------------------------------- + ui.heading("Status"); + status_panel(ui, state, &self.speed); + watch_panel(ui, state, config); + ui.add_space(8.0); + + // --- Controls ------------------------------------------------- + ui.horizontal(|ui| { + let running = !matches!( + state.activity, + IndexingStatus::Idle | IndexingStatus::Error(_) + ); + if ui.add_enabled(!running, egui::Button::new("Start indexing now")).clicked() { + actions.start_now = true; + } + if ui.add_enabled(running || state.mode == IndexMode::Auto, egui::Button::new("Stop")).clicked() { + actions.stop = true; + } + if ui + .add_enabled(state.mode != IndexMode::Auto, egui::Button::new("Return to Automatic")) + .clicked() + { + actions.auto = true; + } + let mode = match state.mode { + IndexMode::Auto => "Automatic", + IndexMode::ManualStopped => "Manual (stopped)", + IndexMode::ManualRunning => "Manual (running)", + }; + ui.label(egui::RichText::new(format!("Mode: {}", mode)).weak()); + ui.separator(); + if ui + .button(egui::RichText::new("Clear index…").color(ui.visuals().error_fg_color)) + .on_hover_text("Delete the index database (asks for confirmation)") + .clicked() + { + actions.clear_index = true; + } + if state.queued_events > 0 { + ui.label( + egui::RichText::new(format!("{} changes queued", state.queued_events)) + .small() + .weak(), + ); + } + }); + ui.separator(); + + // --- Indexed roots --------------------------------------------- + ui.heading("Indexed folders"); + let draft = self.draft.as_mut().expect("synced"); + let mut remove: Option = None; + for (i, root) in draft.paths.indexing_paths.clone().iter().enumerate() { + ui.horizontal(|ui| { + // Controls claim the right edge first so a long path can + // never push them out of view; the path truncates into + // whatever width remains (full path on hover). + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Remove").clicked() { + remove = Some(i); + } + // Per-root walker override; 0 = auto (4 local / 16 + // network, detected per root). Applies on the next run. + let mut workers = + draft.indexing.root_workers.get(root).copied().unwrap_or(0); + let response = ui + .add( + egui::DragValue::new(&mut workers) + .range(0..=64) + .custom_formatter(|n, _| { + if n == 0.0 { + "auto".to_string() + } else { + format!("{:.0}", n) + } + }) + .custom_parser(|s| { + let s = s.trim(); + if s.is_empty() || s.eq_ignore_ascii_case("auto") { + Some(0.0) + } else { + s.parse().ok() + } + }), + ) + .on_hover_text( + "Walker threads for this folder. auto = 4 on local \ + storage, 16 on network mounts. Takes effect on \ + the next indexing run.", + ); + if response.changed() { + if workers == 0 { + draft.indexing.root_workers.remove(root); + } else { + draft.indexing.root_workers.insert(root.clone(), workers); + } + } + ui.label(egui::RichText::new("workers:").small().weak()); + + // Path label takes the leftover width, middle-truncated. + ui.with_layout( + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let font_id = egui::TextStyle::Monospace.resolve(ui.style()); + let char_width = + ui.fonts(|f| f.glyph_width(&font_id, '0')).max(1.0); + let budget = + ((ui.available_width() / char_width) as usize).max(16); + ui.monospace(middle_truncate(root, budget)) + .on_hover_text(root); + }, + ); + }); + }); + } + if let Some(i) = remove { + let removed = draft.paths.indexing_paths.remove(i); + draft.indexing.root_workers.remove(&removed); + } + ui.horizontal(|ui| { + if ui.button("Add folder…").clicked() { + if let Some(dir) = rfd::FileDialog::new().pick_folder() { + let path = dir.to_string_lossy().into_owned(); + try_add_root(draft, path, &mut self.root_error); + } + } + ui.add( + egui::TextEdit::singleline(&mut self.new_root) + .desired_width(240.0) + .hint_text("or type a path"), + ); + if ui.button("Add").clicked() && !self.new_root.trim().is_empty() { + let path = self.new_root.trim().to_string(); + if try_add_root(draft, path, &mut self.root_error) { + self.new_root.clear(); + } + } + }); + if let Some(err) = &self.root_error { + ui.colored_label(ui.visuals().error_fg_color, err); + } + ui.separator(); + + // --- Filters --------------------------------------------------- + ui.heading("Content filters"); + ui.columns(2, |cols| { + cols[0].label("Full-text extensions (empty = all supported):"); + cols[0].add( + egui::TextEdit::multiline(&mut self.ext_filter_text) + .desired_rows(4) + .desired_width(f32::INFINITY) + .hint_text("txt\nmd\npdf"), + ); + cols[1].label("Ignore patterns (excluded entirely):"); + cols[1].add( + egui::TextEdit::multiline(&mut self.ignore_filter_text) + .desired_rows(4) + .desired_width(f32::INFINITY) + .hint_text(".git\nnode_modules\n*.tmp"), + ); + }); + ui.separator(); + + // --- Indexing options ------------------------------------------- + ui.heading("Indexing options"); + config_editor_ui(ui, draft, Section::Indexing); + ui.add_space(4.0); + config_editor_ui(ui, draft, Section::Processing); + ui.add_space(8.0); + + if ui.button("Apply & Save").clicked() { + let mut new_config = draft.clone(); + new_config.indexing.content_extensions = parse_lines(&self.ext_filter_text); + new_config.indexing.ignore_patterns = parse_lines(&self.ignore_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.editors_synced = false; + } + }); + + actions + } +} + +/// Append a root to the draft unless it would duplicate or nest with an +/// existing one; the rejection reason lands in `error`. +fn try_add_root(draft: &mut Config, candidate: String, error: &mut Option) -> bool { + if draft.paths.indexing_paths.contains(&candidate) { + *error = Some(format!("{} is already in the list", candidate)); + return false; + } + let mut probe = draft.paths.indexing_paths.clone(); + probe.push(candidate.clone()); + if let Some((child, parent)) = quicksearch_core::config::nested_roots(&probe).first() { + *error = Some(format!( + "Not added: {} is nested under {}; indexed folders may not overlap", + child, parent + )); + return false; + } + draft.paths.indexing_paths.push(candidate); + *error = None; + true +} + +fn parse_lines(text: &str) -> Vec { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect() +} + +/// Live-update health. Permanent counterpart to the one-time modal: the +/// modal is dismissed and remembered per root, but "live updates are off" +/// stays true and must remain discoverable. +fn watch_panel(ui: &mut egui::Ui, state: &IndexerState, config: &Config) { + match &state.watcher { + // Manual mode already says "stopped" in the controls row; repeating + // it here would be noise. + WatcherStatus::Off => {} + WatcherStatus::Starting => { + ui.label( + egui::RichText::new("Setting up live updates…") + .small() + .weak(), + ); + } + WatcherStatus::Active { dirs } => { + ui.label( + egui::RichText::new(format!( + "Live updates on, watching {} folders", + group_thousands(*dirs as u64) + )) + .small() + .weak(), + ); + } + WatcherStatus::Disabled { reason } => { + ui.colored_label( + ui.visuals().warn_fg_color, + format!( + "⚠ Live updates off; reindexing every {}", + fmt_interval(config.indexing.reindex_interval_minutes) + ), + ) + .on_hover_text(reason.to_string()); + } + } +} + +fn status_panel(ui: &mut egui::Ui, state: &IndexerState, speed: &SpeedTracker) { + match &state.activity { + IndexingStatus::Idle => { + // Relative wording makes even a milliseconds-fast run visibly + // register ("just now") instead of looking like a dead button. + let last = state + .last_full_index + .map(crate::format::fmt_ago) + .unwrap_or_else(|| "never".to_string()); + ui.label(format!("Idle; last full index: {}", last)); + } + IndexingStatus::Error(e) => { + ui.colored_label(ui.visuals().error_fg_color, format!("Error: {}", e)); + } + IndexingStatus::Stopping => { + ui.label("Stopping…"); + } + IndexingStatus::Running { roots, .. } => { + for root in roots { + root_row(ui, root); + } + if let Some(rate) = speed.files_per_sec() { + ui.label( + egui::RichText::new(format!("overall: {}", fmt_rate(rate))) + .small() + .weak(), + ); + } + } + } +} + +/// One root's progress: path, phase, bar, counters, current file. +fn root_row(ui: &mut egui::Ui, r: &RootProgress) { + // Weak "|" separators split the row into folder | status | numbers. + let divider = |ui: &mut egui::Ui| { + ui.label(egui::RichText::new("|").weak()); + }; + ui.horizontal(|ui| { + ui.monospace(middle_truncate(&r.root, 48)); + divider(ui); + match r.phase { + RootPhase::Walking => { + ui.label("indexing"); + divider(ui); + let workers = format!("{}/{} workers", r.active_workers, r.total_workers); + match r.walk_total { + Some(total) if total > 0 => { + let frac = (r.walked as f32 / total as f32).clamp(0.0, 1.0); + ui.label(format!( + "{} / {} ({:.0}%) · {}", + group_thousands(r.walked as u64), + group_thousands(total as u64), + frac * 100.0, + workers + )); + ui.add(egui::ProgressBar::new(frac).desired_width(160.0)); + } + _ => { + ui.label(format!( + "{} files · {}", + group_thousands(r.walked as u64), + workers + )); + ui.add(egui::ProgressBar::new(0.0).animate(true).desired_width(160.0)); + } + } + } + RootPhase::Extracting => { + ui.label("extracting text for search"); + divider(ui); + let frac = if r.extract_total > 0 { + (r.extracted as f32 / r.extract_total as f32).clamp(0.0, 1.0) + } else { + 1.0 + }; + ui.label(format!( + "{} / {} ({:.0}%)", + group_thousands(r.extracted as u64), + group_thousands(r.extract_total as u64), + frac * 100.0 + )); + ui.add(egui::ProgressBar::new(frac).desired_width(160.0)); + } + RootPhase::Done => { + // Whole-root totals: `walked` covers every file the walk + // saw (including unchanged, skipped ones) and `extracted` + // covers all rows with searchable text, not just this + // run's new work. + ui.label("done"); + divider(ui); + ui.label(format!( + "indexed {}, extracted {}", + group_thousands(r.walked as u64), + group_thousands(r.extracted as u64) + )); + ui.add(egui::ProgressBar::new(1.0).desired_width(160.0)); + } + } + }); + if let Some(f) = &r.current_file { + ui.label(egui::RichText::new(middle_truncate(f, 90)).small().weak()); + } +} diff --git a/crates/quicksearch-gui/src/options.rs b/crates/quicksearch-gui/src/options.rs new file mode 100644 index 0000000..db4b5c2 --- /dev/null +++ b/crates/quicksearch-gui/src/options.rs @@ -0,0 +1,251 @@ +//! The Options window and the shared config editor used by both the +//! window and the Manage Index tab. Edits happen on a draft; Apply +//! validates, saves, and hands the new config to the app. + +use quicksearch_core::config::Config; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Section { + Indexing, + Processing, + Search, +} + +pub struct OptionsWindow { + pub open: bool, + draft: Option, +} + +impl OptionsWindow { + pub fn new() -> OptionsWindow { + OptionsWindow { + open: false, + draft: None, + } + } + + pub fn open_with(&mut self, current: &Config) { + self.open = true; + self.draft = Some(current.clone()); + } + + /// Render; returns a new config when the user applied changes. + pub fn ui(&mut self, ctx: &egui::Context, current: &Config) -> Option { + if !self.open { + self.draft = None; + return None; + } + if self.draft.is_none() { + self.draft = Some(current.clone()); + } + let mut applied = None; + let mut open = self.open; + let draft = self.draft.as_mut().unwrap(); + + egui::Window::new("Options") + .open(&mut open) + .resizable(false) + .default_width(420.0) + .show(ctx, |ui| { + egui::ScrollArea::vertical().max_height(480.0).show(ui, |ui| { + ui.heading("Paths"); + 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.separator(); + + ui.heading("Indexing"); + config_editor_ui(ui, draft, Section::Indexing); + ui.separator(); + + ui.heading("Processing"); + config_editor_ui(ui, draft, Section::Processing); + ui.separator(); + + ui.heading("Search"); + config_editor_ui(ui, draft, Section::Search); + ui.separator(); + + ui.heading("Interface"); + 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.horizontal(|ui| { + if ui.button("Apply & Save").clicked() { + applied = Some(draft.clone()); + } + 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; + } + applied + } +} + +/// One implementation of the per-section config controls, shared by the +/// Options window and the Manage tab. +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| { + ui.label("Automatic indexing"); + ui.checkbox(&mut config.indexing.auto_index, "watchers + periodic reindex"); + ui.end_row(); + + 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(); + }); + } + 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(); + + 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 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("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| { + ui.label("Fuzzy stages on by default"); + ui.checkbox(&mut config.search.fuzzy_default, ""); + ui.end_row(); + + ui.label("Fuzzy edit distance"); + ui.vertical(|ui| { + ui.add(egui::DragValue::new(&mut config.search.fuzzy_max_edits).range(0..=8)) + .on_hover_text( + "Ceiling on the typo budget. The allowance grows with the \ + search term, one edit per three characters, up to this \ + value, so 2 means \"1 edit for short terms, 2 for longer \ + ones\". 0 turns the fuzzy stages off.", + ); + if let Some(warning) = config.search.fuzzy_edits_warning() { + ui.label( + egui::RichText::new(warning) + .small() + .color(egui::Color32::from_rgb(220, 150, 40)), + ); + } + }); + ui.end_row(); + + ui.label("Display limit"); + ui.add(egui::DragValue::new(&mut config.search.display_limit).range(50..=100_000)); + ui.end_row(); + + ui.label("Stream batch size"); + ui.add( + egui::DragValue::new(&mut config.search.results_per_page).range(10..=10_000), + ); + ui.end_row(); + + ui.label("Debounce (ms)"); + ui.add(egui::DragValue::new(&mut config.search.debounce_ms).range(0..=2000)); + ui.end_row(); + + ui.label("Fuzzy max edits"); + ui.add(egui::DragValue::new(&mut config.search.fuzzy_max_edits).range(0..=8)) + .on_hover_text( + "Ceiling on fuzzy edit distance (the budget grows one \ + edit per three characters of the term). 0 disables \ + the fuzzy passes.", + ); + ui.end_row(); + }); + if let Some(warning) = config.search.fuzzy_edits_warning() { + ui.colored_label(ui.visuals().warn_fg_color, warning); + } + } + } +} diff --git a/crates/quicksearch-gui/src/platform.rs b/crates/quicksearch-gui/src/platform.rs new file mode 100644 index 0000000..8298fdc --- /dev/null +++ b/crates/quicksearch-gui/src/platform.rs @@ -0,0 +1,134 @@ +//! Opening files, revealing them in the system file manager, and the one bit +//! of process setup that has to happen before anything prints. + +use std::process::Command; + +/// Give the process somewhere to write when it has no stdio. +/// +/// A window-subsystem binary launched from Explorer has NULL standard handles, +/// and `println!`/`eprintln!` *panic* when the write fails rather than +/// dropping the output. Pointing the handles at `NUL` makes those writes +/// succeed and go nowhere. +/// +/// Background reporting no longer depends on this — it goes through +/// [`quicksearch_core::log`], which ignores a failed stderr write and keeps +/// the line for the Logs tab — but the remaining direct prints (a startup +/// failure, a panic message) still reach a handle that accepts them. +/// +/// Handles inherited from a real console are left alone, so running the binary +/// from a shell still prints normally. +#[cfg(windows)] +pub fn redirect_null_stdio() { + use std::os::windows::io::IntoRawHandle; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::{ + GetStdHandle, SetStdHandle, STD_ERROR_HANDLE, STD_OUTPUT_HANDLE, + }; + + for id in [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { + let existing = unsafe { GetStdHandle(id) }; + if !existing.is_null() && existing != INVALID_HANDLE_VALUE { + continue; + } + if let Ok(file) = std::fs::OpenOptions::new().write(true).open("NUL") { + // Deliberately leaked: the handle has to outlive every later + // write, which means the whole process. + unsafe { SetStdHandle(id, file.into_raw_handle() as _) }; + } + } +} + +/// Open a file with the desktop's default application, detached. +pub fn open_file(path: &str) { + if let Err(e) = open::that_detached(path) { + quicksearch_core::log_warn!("open {}: {}", path, e); + } +} + +/// Reveal a file in the system file manager with the file selected. +/// +/// Linux: `org.freedesktop.FileManager1.ShowItems` over the session bus +/// (supported by every mainstream file manager) via `dbus-send` — no +/// D-Bus library dependency for one call. Falls back to opening the +/// parent directory. Windows/macOS use their native select verbs. +pub fn reveal_in_folder(path: &str) { + #[cfg(target_os = "windows")] + { + use std::os::windows::process::CommandExt; + /// Keep a console window from flashing behind the spawn. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + // explorer.exe parses its own command line rather than using the + // standard argv splitting, and wants `/select,` glued to the path as a + // single token with quotes around the path only. Passed as two + // arguments it ignores the selection and just opens the folder, and + // std's quoting would wrap the whole token. `raw_arg` is the only way + // to say exactly this. + // + // Forward slashes are valid everywhere else on Windows but not here, + // so normalize first. The exit code is not worth checking: explorer + // returns 1 even on success. + let native = path.replace('/', "\\"); + let _ = Command::new("explorer.exe") + .raw_arg(format!("/select,\"{}\"", native)) + .creation_flags(CREATE_NO_WINDOW) + .spawn(); + return; + } + #[cfg(target_os = "macos")] + { + let _ = Command::new("open").arg("-R").arg(path).spawn(); + return; + } + #[cfg(all(unix, not(target_os = "macos")))] + { + use std::path::Path; + let uri = format!("file://{}", uri_escape_path(path)); + let ok = Command::new("dbus-send") + .args([ + "--session", + "--print-reply", + "--dest=org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + &format!("array:string:{}", uri), + "string:", + ]) + .output() + .map(|out| out.status.success()) + .unwrap_or(false); + if !ok { + let parent = Path::new(path).parent().unwrap_or(Path::new("/")); + let _ = Command::new("xdg-open").arg(parent).spawn(); + } + } +} + +/// Percent-encode a filesystem path for a file:// URI, keeping `/`. +#[cfg(all(unix, not(target_os = "macos")))] +fn uri_escape_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for byte in path.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'/' | b'-' | b'_' | b'.' | b'~' => { + out.push(byte as char) + } + _ => out.push_str(&format!("%{:02X}", byte)), + } + } + out +} + +#[cfg(test)] +mod tests { + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn uri_escaping() { + use super::uri_escape_path; + assert_eq!(uri_escape_path("/plain/path.txt"), "/plain/path.txt"); + assert_eq!( + uri_escape_path("/with space/ünïcode&.txt"), + "/with%20space/%C3%BCn%C3%AFcode%26.txt" + ); + } +} diff --git a/crates/quicksearch-gui/src/query_highlight.rs b/crates/quicksearch-gui/src/query_highlight.rs new file mode 100644 index 0000000..a86ff0e --- /dev/null +++ b/crates/quicksearch-gui/src/query_highlight.rs @@ -0,0 +1,739 @@ +//! Syntax highlighting for the search box. +//! +//! [`classify`] is a pure token walk over [`tokenize_spanned`] output that +//! mirrors `split_for_cascade` branch for branch — it must never claim +//! something is a filter (or a wildcard) that the engine treats as plain +//! text. The egui layer at the bottom turns its segments into a `Galley` +//! for `TextEdit::layouter`. +//! +//! Color scheme: recognized keywords red, their arguments blue, syntax +//! characters (operators, quotes, live wildcards) green, invalid arguments +//! in the error color, everything else plain. A complete recognized filter +//! additionally gets a tinted background chip. + +use std::ops::Range; +use std::sync::Arc; + +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, Galley, Stroke}; +use quicksearch_core::query::ast::Op; +use quicksearch_core::query::lexer::{tokenize_spanned, Token}; +use quicksearch_core::query::pattern::RegexQuery; +use quicksearch_core::query::translator::{build_filter, is_filter_key}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Class { + Plain, + /// The key word of a recognized filter (`type`, `name`, `regex`, …). + Keyword, + /// Syntax characters doing work: filter operators (`:`, `:>=`, …), + /// quote delimiters, and `*` where it is a live wildcard. + Operator, + /// The value of a recognized filter. + Argument, + /// The value of a recognized filter that the engine would reject + /// (unknown type name, bad date, invalid regex). + InvalidArg, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Seg { + pub range: Range, + pub class: Class, + /// Part of a complete recognized filter — drawn on the chip tint. + pub chip: bool, +} + +/// Classify `text` into contiguous segments tiling `0..text.len()`. +pub fn classify(text: &str) -> Vec { + let (tokens, err) = tokenize_spanned(text); + let mut em = Emitter { + text, + cursor: 0, + segs: Vec::new(), + }; + + let tok = |i: usize| tokens.get(i).map(|(t, _)| t); + let span = |i: usize| tokens[i].1.clone(); + + let mut i = 0usize; + // The engine allows one `regex:` per query; later ones are errors. + let mut regex_seen = false; + + while i < tokens.len() { + match &tokens[i].0 { + Token::Word(word) => { + if let Some(Token::Op(op1)) = tok(i + 1) { + // Candidate filter: Word(key) Op [Op] (Word|Quoted), + // exactly as split_for_cascade sees it. + let (op, op_end_idx, value_idx) = match tok(i + 2) { + Some(Token::Op(op2)) => (*op2, i + 2, i + 3), + _ => (*op1, i + 1, i + 2), + }; + let value = match tok(value_idx) { + Some(Token::Word(v)) | Some(Token::Quoted(v)) => Some(v.clone()), + _ => None, + }; + let is_regex = word.eq_ignore_ascii_case("regex"); + if let Some(value) = value { + let value_is_word = matches!(tok(value_idx), Some(Token::Word(_))); + if is_regex || is_filter_key(word) { + let valid = if is_regex { + let first = !regex_seen; + regex_seen = true; + first + && op == Op::Contains + && RegexQuery::new(&value).is_ok() + } else { + build_filter(word, op, &value, value_is_word).is_ok() + }; + em.emit(span(i), Class::Keyword, true, false); + for op_idx in (i + 1)..=op_end_idx { + em.emit(span(op_idx), Class::Operator, true, true); + } + let vspan = span(value_idx); + if !valid { + // One uniform error run reads better than + // error-with-green-sprinkles. + em.emit(vspan, Class::InvalidArg, true, true); + } else if !value_is_word { + em.emit_quoted(vspan, Class::Argument, true); + } else if glob_value_key(word) { + em.emit_word(vspan, Class::Argument, true, true); + } else { + // Stars in other filter values are literal + // characters — no wildcard color. + em.emit(vspan, Class::Argument, true, true); + } + i = value_idx + 1; + continue; + } + // Unrecognized key: the engine reassembles the whole + // chain verbatim (stars stay literal), so everything + // renders plain — that absence of color is how the + // user learns `foo:` is not a filter. + em.emit(span(i), Class::Plain, false, false); + for op_idx in (i + 1)..=op_end_idx { + em.emit(span(op_idx), Class::Plain, false, false); + } + em.emit_glued_value(span(value_idx), tok(value_idx)); + i = value_idx + 1; + while let Some(Token::Op(_)) = tok(i) { + em.emit(span(i), Class::Plain, false, false); + i += 1; + if let Some(Token::Word(_)) | Some(Token::Quoted(_)) = tok(i) { + em.emit_glued_value(span(i), tok(i)); + i += 1; + } + } + continue; + } + // Key + op with no value yet (mid-typing `type:`). + // Recognized keys color optimistically — instant + // feedback that the key landed — but earn no chip + // until the filter is complete. + let known = is_regex || is_filter_key(word); + let (key_class, op_class) = if known { + (Class::Keyword, Class::Operator) + } else { + (Class::Plain, Class::Plain) + }; + em.emit(span(i), key_class, false, false); + for op_idx in (i + 1)..=op_end_idx { + em.emit(span(op_idx), op_class, false, false); + } + i = op_end_idx + 1; + continue; + } + // A plain word: unquoted stars are live wildcards. + em.emit_word(span(i), Class::Plain, false, false); + } + Token::Quoted(_) => em.emit_quoted(span(i), Class::Plain, false), + // Demoted to plain text by the live search path — coloring + // them as operators would lie. + Token::And | Token::Or | Token::LParen | Token::RParen | Token::Op(_) => { + em.emit(span(i), Class::Plain, false, false); + } + } + i += 1; + } + + // Trailing lex error: an unterminated quote is a quote-in-progress, + // not a mistake — green delimiter, plain tail. + if let Some(err) = err { + if err.offset < text.len() && text.as_bytes()[err.offset] == b'"' { + em.emit(err.offset..err.offset + 1, Class::Operator, false, false); + } + } + em.finish(text.len()) +} + +/// Keys whose word-form values interpret `*` as a wildcard (or, for +/// `regex`, as live pattern syntax). +fn glob_value_key(key: &str) -> bool { + matches!( + key.to_ascii_lowercase().as_str(), + "name" | "filename" | "regex" + ) +} + +struct Emitter<'a> { + text: &'a str, + cursor: usize, + segs: Vec, +} + +impl Emitter<'_> { + /// Fill the gap (whitespace the lexer skipped) up to `pos`. + fn gap_to(&mut self, pos: usize, chip: bool) { + if pos > self.cursor { + self.segs.push(Seg { + range: self.cursor..pos, + class: Class::Plain, + chip, + }); + self.cursor = pos; + } + } + + /// Emit one span. `gap_chip` tints the whitespace before it — true for + /// the interior of a filter (`type : Audio` chips as one run). + fn emit(&mut self, range: Range, class: Class, chip: bool, gap_chip: bool) { + self.gap_to(range.start, gap_chip); + if range.end > range.start { + self.segs.push(Seg { + range: range.clone(), + class, + chip, + }); + self.cursor = range.end; + } + } + + /// Emit a word span with each `*` as a green wildcard and the pieces + /// between in `base`. + fn emit_word(&mut self, range: Range, base: Class, chip: bool, gap_chip: bool) { + self.gap_to(range.start, gap_chip); + let bytes = self.text.as_bytes(); + let mut piece_start = range.start; + for pos in range.clone() { + if bytes[pos] == b'*' { + if pos > piece_start { + self.segs.push(Seg { + range: piece_start..pos, + class: base, + chip, + }); + } + self.segs.push(Seg { + range: pos..pos + 1, + class: Class::Operator, + chip, + }); + piece_start = pos + 1; + } + } + if range.end > piece_start { + self.segs.push(Seg { + range: piece_start..range.end, + class: base, + chip, + }); + } + self.cursor = self.cursor.max(range.end); + } + + /// Emit a quoted span (delimiters included): quotes green, content in + /// `content`. Inner `""` escapes are just content bytes — no offset + /// math needed. + fn emit_quoted(&mut self, range: Range, content: Class, chip: bool) { + self.gap_to(range.start, chip); + self.segs.push(Seg { + range: range.start..range.start + 1, + class: Class::Operator, + chip, + }); + if range.end - range.start > 2 { + self.segs.push(Seg { + range: range.start + 1..range.end - 1, + class: content, + chip, + }); + } + if range.end - range.start >= 2 { + self.segs.push(Seg { + range: range.end - 1..range.end, + class: Class::Operator, + chip, + }); + } + self.cursor = self.cursor.max(range.end); + } + + /// A value inside unrecognized-key glue: plain, except quote + /// delimiters, which still did real tokenizing work. + fn emit_glued_value(&mut self, range: Range, token: Option<&Token>) { + match token { + Some(Token::Quoted(_)) => self.emit_quoted(range, Class::Plain, false), + _ => self.emit(range, Class::Plain, false, false), + } + } + + fn finish(mut self, len: usize) -> Vec { + self.gap_to(len, false); + self.segs + } +} + +// --------------------------------------------------------------------------- +// egui layer +// --------------------------------------------------------------------------- + +struct QueryPalette { + keyword: Color32, + argument: Color32, + operator: Color32, +} + +/// GitHub Primer syntax colors — readable on egui's near-black and white +/// text-field backgrounds. Same convention as `rank_tier_color`. +fn query_palette(dark_mode: bool) -> QueryPalette { + if dark_mode { + QueryPalette { + keyword: Color32::from_rgb(255, 123, 114), + argument: Color32::from_rgb(121, 192, 255), + operator: Color32::from_rgb(126, 231, 135), + } + } else { + QueryPalette { + keyword: Color32::from_rgb(207, 34, 46), + argument: Color32::from_rgb(5, 80, 174), + operator: Color32::from_rgb(26, 127, 55), + } + } +} + +struct QueryFormats { + plain: TextFormat, + keyword: TextFormat, + operator: TextFormat, + argument: TextFormat, + invalid: TextFormat, + chip_bg: Color32, +} + +fn query_formats(ui: &egui::Ui) -> QueryFormats { + let font_id = egui::TextStyle::Body.resolve(ui.style()); + let palette = query_palette(ui.visuals().dark_mode); + let base = |color: Color32| TextFormat { + font_id: font_id.clone(), + color, + ..Default::default() + }; + let error = ui.visuals().error_fg_color; + QueryFormats { + plain: base(ui.visuals().text_color()), + keyword: base(palette.keyword), + operator: base(palette.operator), + argument: base(palette.argument), + // The keyword red and the error red are near neighbors in dark + // mode; the underline disambiguates at a glance. + invalid: TextFormat { + underline: Stroke::new(1.0, error), + ..base(error) + }, + // Slightly weaker than the snippet highlight's 0.4 so the colored + // text on top stays crisp. + chip_bg: ui.visuals().selection.bg_fill.gamma_multiply(0.35), + } +} + +impl QueryFormats { + fn format_for(&self, seg: &Seg) -> TextFormat { + let mut fmt = match seg.class { + Class::Plain => self.plain.clone(), + Class::Keyword => self.keyword.clone(), + Class::Operator => self.operator.clone(), + Class::Argument => self.argument.clone(), + Class::InvalidArg => self.invalid.clone(), + }; + if seg.chip { + fmt.background = self.chip_bg; + } + fmt + } +} + +/// Classification cache: tokenizing is cheap but validating a `regex:` +/// argument compiles the regex, and the layouter runs every frame — so +/// segments are recomputed only when the text changes. The `LayoutJob` is +/// rebuilt each frame (colors follow the live theme) and epaint's own +/// galley cache dedupes the actual layout work by job hash. +#[derive(Default)] +pub struct HighlightCache { + text: String, + segs: Vec, +} + +pub fn galley(ui: &egui::Ui, cache: &mut HighlightCache, text: &str) -> Arc { + if cache.text != text { + cache.text = text.to_owned(); + cache.segs = classify(text); + } + let fmts = query_formats(ui); + let mut job = LayoutJob::default(); + for seg in &cache.segs { + job.append(&text[seg.range.clone()], 0.0, fmts.format_for(seg)); + } + ui.fonts(|f| f.layout_job(job)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Readable projection: (slice, class, chip) per segment. + fn segs(text: &str) -> Vec<(String, Class, bool)> { + assert_tiles(text); + classify(text) + .into_iter() + .map(|s| (text[s.range.clone()].to_string(), s.class, s.chip)) + .collect() + } + + /// Segments must tile 0..len exactly: contiguous, ascending, complete. + fn assert_tiles(text: &str) { + let segs = classify(text); + let mut cursor = 0usize; + for s in &segs { + assert_eq!(s.range.start, cursor, "gap or overlap in {:?}: {:?}", text, segs); + assert!(s.range.end > s.range.start, "empty seg in {:?}", text); + cursor = s.range.end; + } + assert_eq!(cursor, text.len(), "segments must cover {:?}", text); + } + + use Class::*; + + fn owned(v: Vec<(&str, Class, bool)>) -> Vec<(String, Class, bool)> { + v.into_iter().map(|(s, c, b)| (s.to_string(), c, b)).collect() + } + + #[test] + fn empty_input_yields_no_segments() { + assert!(classify("").is_empty()); + } + + #[test] + fn plain_words_stay_plain() { + assert_eq!( + segs("budget report"), + owned(vec![ + ("budget", Plain, false), + (" ", Plain, false), + ("report", Plain, false), + ]) + ); + } + + #[test] + fn every_recognized_filter_chips() { + for input in [ + "type:Audio", + "modified:>=2024-01-01", + "mtime:<2023-12-01", + "path:/home/me", + "folder:/x", + "includefolder:/x", + "name:report", + "filename:report", + "mime:application/pdf", + "regex:foo", + ] { + let all = segs(input); + assert!( + all.iter().all(|(_, _, chip)| *chip), + "{:?}: whole filter must chip: {:?}", + input, + all + ); + assert_eq!(all[0].1, Keyword, "{:?}", input); + assert_eq!(all[1].1, Operator, "{:?}", input); + assert!( + all[2..].iter().all(|(_, c, _)| *c == Argument || *c == Operator), + "{:?}: {:?}", + input, + all + ); + } + } + + #[test] + fn keys_are_case_insensitive() { + assert_eq!(segs("TYPE:Audio")[0], ("TYPE".to_string(), Keyword, true)); + assert_eq!( + segs("Modified:>=2024-01-01")[0], + ("Modified".to_string(), Keyword, true) + ); + } + + #[test] + fn colon_comparator_runs_are_one_green_stretch() { + assert_eq!( + segs("modified:>=2024-01-01"), + owned(vec![ + ("modified", Keyword, true), + (":", Operator, true), + (">=", Operator, true), + ("2024-01-01", Argument, true), + ]) + ); + } + + #[test] + fn unrecognized_keys_stay_plain() { + for input in ["foo:bar", "12:30", "foo:bar:baz"] { + assert!( + segs(input).iter().all(|(_, c, chip)| *c == Plain && !chip), + "{:?}: {:?}", + input, + segs(input) + ); + } + // Stars in glue are literal to the engine — no green. + assert!(segs("foo:ba*r").iter().all(|(_, c, _)| *c == Plain)); + } + + #[test] + fn drive_letters_do_not_split() { + assert_eq!( + segs(r"path:C:\Users\me"), + owned(vec![ + ("path", Keyword, true), + (":", Operator, true), + (r"C:\Users\me", Argument, true), + ]) + ); + assert_eq!( + segs(r"C:\data"), + owned(vec![(r"C:\data", Plain, false)]) + ); + } + + #[test] + fn quoted_phrases_get_green_delimiters() { + assert_eq!( + segs("\"exact phrase\""), + owned(vec![ + ("\"", Operator, false), + ("exact phrase", Plain, false), + ("\"", Operator, false), + ]) + ); + // Inner "" escapes are content bytes. + assert_eq!( + segs("\"a\"\"b\""), + owned(vec![ + ("\"", Operator, false), + ("a\"\"b", Plain, false), + ("\"", Operator, false), + ]) + ); + // Quoted stars are literal — content stays plain. + assert!(segs("\"a*b\"").iter().all(|(s, c, _)| s == "\"" + || *c == Plain)); + } + + #[test] + fn quoted_filter_values_are_blue_with_green_quotes() { + assert_eq!( + segs("path:\"/home/me/My Docs\""), + owned(vec![ + ("path", Keyword, true), + (":", Operator, true), + ("\"", Operator, true), + ("/home/me/My Docs", Argument, true), + ("\"", Operator, true), + ]) + ); + // Empty quoted value: two delimiters, no content seg, no panic. + assert_eq!( + segs("path:\"\""), + owned(vec![ + ("path", Keyword, true), + (":", Operator, true), + ("\"", Operator, true), + ("\"", Operator, true), + ]) + ); + } + + #[test] + fn unterminated_quote_is_a_quote_in_progress() { + assert_eq!( + segs("\"unclosed phrase"), + owned(vec![ + ("\"", Operator, false), + ("unclosed phrase", Plain, false), + ]) + ); + // Filters before the open quote keep their colors. + let all = segs("type:Audio \"x"); + assert_eq!(all[0], ("type".to_string(), Keyword, true)); + assert_eq!(all[4], ("\"".to_string(), Operator, false)); + assert_eq!(all[5], ("x".to_string(), Plain, false)); + } + + #[test] + fn trailing_bare_keys_color_optimistically_without_chip() { + assert_eq!( + segs("type:"), + owned(vec![("type", Keyword, false), (":", Operator, false)]) + ); + assert_eq!( + segs("modified:>="), + owned(vec![ + ("modified", Keyword, false), + (":", Operator, false), + (">=", Operator, false), + ]) + ); + assert_eq!( + segs("foo:"), + owned(vec![("foo", Plain, false), (":", Plain, false)]) + ); + } + + #[test] + fn stars_in_words_and_name_values_go_green() { + assert_eq!( + segs("rep*ort"), + owned(vec![ + ("rep", Plain, false), + ("*", Operator, false), + ("ort", Plain, false), + ]) + ); + assert_eq!( + segs("name:re*.txt"), + owned(vec![ + ("name", Keyword, true), + (":", Operator, true), + ("re", Argument, true), + ("*", Operator, true), + (".txt", Argument, true), + ]) + ); + // Edge and doubled stars keep tiling intact. + assert_tiles("*foo"); + assert_tiles("foo*"); + assert_tiles("**"); + assert_tiles("*"); + // In non-glob filter values the star is a literal character. + assert_eq!( + segs("path:/da*ta")[2], + ("/da*ta".to_string(), Argument, true) + ); + } + + #[test] + fn invalid_arguments_go_error_uniformly() { + // (`regex:(` is not here: `(` lexes as a paren, so that input is an + // *incomplete* filter — bare-key optimism applies, not an error.) + for input in ["type:NotAThing", "modified:>=tomorrow", "regex:[", "type:Doc*"] { + let all = segs(input); + assert_eq!(all[0].1, Keyword, "{:?}", input); + let last = all.last().unwrap(); + assert_eq!(last.1, InvalidArg, "{:?}: {:?}", input, all); + assert!(last.2, "invalid values keep the chip: {:?}", input); + } + // name:= is an unsupported op → its value is invalid too. + let all = segs("name=x"); + assert_eq!(all.last().unwrap().1, InvalidArg); + } + + #[test] + fn valid_regex_argument_is_blue_with_green_stars() { + assert_eq!( + segs("regex:foo.*bar"), + owned(vec![ + ("regex", Keyword, true), + (":", Operator, true), + ("foo.", Argument, true), + ("*", Operator, true), + ("bar", Argument, true), + ]) + ); + } + + #[test] + fn a_second_regex_filter_is_invalid() { + let all = segs("regex:foo regex:bar"); + assert_eq!(all[2], ("foo".to_string(), Argument, true)); + assert_eq!(all.last().unwrap(), &("bar".to_string(), InvalidArg, true)); + } + + #[test] + fn multi_filter_queries_chip_separately() { + let all = segs("type:Document budget modified:>=2024-01-01"); + // The word and the whitespace around it stay un-chipped. + assert_eq!( + all.iter() + .filter(|(_, _, chip)| !chip) + .map(|(s, _, _)| s.as_str()) + .collect::>(), + vec![" ", "budget", " "] + ); + } + + #[test] + fn spaced_filters_chip_their_interior_gaps() { + // `type : Audio` is still a filter to the lexer/splitter. + assert_eq!( + segs("type : Audio"), + owned(vec![ + ("type", Keyword, true), + (" ", Plain, true), + (":", Operator, true), + (" ", Plain, true), + ("Audio", Argument, true), + ]) + ); + } + + #[test] + fn demoted_operators_stay_plain() { + assert!( + segs("(alpha AND beta) OR gamma") + .iter() + .all(|(_, c, chip)| *c == Plain && !chip) + ); + // Dangling comparators are literal text. + assert!(segs("a > b").iter().all(|(_, c, _)| *c == Plain)); + // Leading operator, nothing else. + assert!(segs(">foo").iter().all(|(_, c, _)| *c == Plain)); + } + + #[test] + fn adjacency_between_filter_and_quote() { + // `Audio"q"`: the word ends at the quote; the filter is complete + // and the quoted phrase stands alone. + let all = segs("type:Audio\"q\""); + assert_eq!(all[2], ("Audio".to_string(), Argument, true)); + assert_eq!(all[3], ("\"".to_string(), Operator, false)); + } + + #[test] + fn unicode_offsets_hold_up() { + assert_tiles("\"José\" type:Audio naïve*file"); + let all = segs("naïve*café"); + assert_eq!( + all, + owned(vec![ + ("naïve", Plain, false), + ("*", Operator, false), + ("café", Plain, false), + ]) + ); + } +} diff --git a/crates/quicksearch-gui/src/search.rs b/crates/quicksearch-gui/src/search.rs deleted file mode 100644 index 7fd6b32..0000000 --- a/crates/quicksearch-gui/src/search.rs +++ /dev/null @@ -1,527 +0,0 @@ -#![allow(non_snake_case)] - -use std::sync::Arc; -use std::time::Instant; -use dioxus::prelude::*; -use quicksearch_core::indexing::{IndexingService, SearchResult}; -use quicksearch_core::search_sql::{build_count, build_select, SearchArgs}; - -/// One page of results. Tuned to keep DOM size bounded — rendering ten -/// thousand `` nodes wedges WebKit for tens of seconds. -const PAGE_SIZE: u32 = 50; - -#[derive(Props, Clone)] -pub struct SearchProps { - pub indexing_service: Arc, - pub db_path: String, -} - -impl PartialEq for SearchProps { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.indexing_service, &other.indexing_service) && self.db_path == other.db_path - } -} - -pub fn Search(props: SearchProps) -> Element { - let mut search_type = use_signal(|| "fulltext".to_string()); - let mut search_term = use_signal(|| String::new()); - let mut fulltext_exact = use_signal(|| false); - let mut fulltext_case_sensitive = use_signal(|| false); - let mut search_results = use_signal(|| Vec::::new()); - let mut search_error = use_signal(|| None::); - let mut is_searching = use_signal(|| false); - let mut last_search_time = use_signal(|| None::); - let mut show_corruption_dialog = use_signal(|| false); - let mut current_page = use_signal(|| 1u32); - let mut total_count = use_signal(|| None::); - let mut last_args = use_signal(|| None::); - let mut goto_input = use_signal(|| String::new()); - - let service = props.indexing_service.clone(); - let db_path = props.db_path.clone(); - - // Spawn a search task. `refresh_count` is true for fresh searches and - // false for in-place page navigation (the cached total still applies). - let run_query = { - let service = service.clone(); - let db_path = db_path.clone(); - move |args: SearchArgs, page: u32, refresh_count: bool| { - let service = service.clone(); - let db_path = db_path.clone(); - spawn(async move { - is_searching.set(true); - search_error.set(None); - last_search_time.set(None); - let start = Instant::now(); - - let count_sql = if refresh_count { - match build_count(&args) { - Ok(s) => Some(s), - Err(e) => { - search_error.set(Some(e)); - is_searching.set(false); - return; - } - } - } else { - None - }; - - let offset = page.saturating_sub(1).saturating_mul(PAGE_SIZE); - // Validate early for the filename/duplicates branch so we - // surface parse errors before dispatching the blocking task. - let precomputed_select_sql = if args.search_type == "fulltext" { - None - } else { - match build_select(&args, PAGE_SIZE, offset) { - Ok(s) => Some(s), - Err(e) => { - search_error.set(Some(e)); - is_searching.set(false); - return; - } - } - }; - - // Fulltext takes the snippet-aware path (decompresses - // documents_text and highlights in Rust); filename + - // duplicates go through the plain SQL executor. - let svc1 = service.clone(); - let db1 = db_path.clone(); - let args_for_select = args.clone(); - let select_handle = tokio::task::spawn_blocking(move || { - if args_for_select.search_type == "fulltext" { - svc1.execute_fulltext_search(&db1, &args_for_select, PAGE_SIZE, offset) - } else { - let sql = precomputed_select_sql - .expect("non-fulltext select SQL was prebuilt above"); - svc1.execute_search(&db1, &sql) - } - }); - - let count_handle = count_sql.map(|sql| { - let svc2 = service.clone(); - let db2 = db_path.clone(); - tokio::task::spawn_blocking(move || svc2.execute_search(&db2, &sql)) - }); - - let select_run = select_handle.await; - let count_run = match count_handle { - Some(h) => Some(h.await), - None => None, - }; - - let elapsed = start.elapsed().as_secs_f64(); - - if let Some(c) = count_run { - match c { - Ok(Ok(rs)) => { - let n = rs - .first() - .and_then(|r| r.rows.first()) - .and_then(|r| r.values.first()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0); - total_count.set(Some(n)); - } - Ok(Err(e)) => { - handle_query_error( - e, - elapsed, - search_error, - show_corruption_dialog, - last_search_time, - is_searching, - ); - return; - } - Err(e) => { - search_error.set(Some(format!("Task execution error: {}", e))); - last_search_time.set(Some(elapsed)); - is_searching.set(false); - return; - } - } - } - - match select_run { - Ok(Ok(rs)) => { - search_results.set(rs); - current_page.set(page); - last_args.set(Some(args)); - last_search_time.set(Some(elapsed)); - } - Ok(Err(e)) => { - handle_query_error( - e, - elapsed, - search_error, - show_corruption_dialog, - last_search_time, - is_searching, - ); - return; - } - Err(e) => { - search_error.set(Some(format!("Task execution error: {}", e))); - last_search_time.set(Some(elapsed)); - } - } - is_searching.set(false); - }); - } - }; - - let do_fresh_search = { - let run_query = run_query.clone(); - move || { - let args = SearchArgs { - search_type: search_type(), - term: search_term(), - fulltext_exact: fulltext_exact(), - fulltext_case_sensitive: fulltext_case_sensitive(), - }; - run_query(args, 1, true); - } - }; - - let do_goto_page = { - let run_query = run_query.clone(); - move |target: u32| { - if let Some(args) = last_args() { - let total = total_count() - .map(|n| pages_for(n)) - .unwrap_or(1) - .max(1); - let clamped = target.clamp(1, total); - if clamped != current_page() { - run_query(args, clamped, false); - } - } - } - }; - - let total_pages = total_count().map(pages_for).unwrap_or(0); - - rsx! { - div { - class: "section", - h2 { "Search Database" } - - div { - class: "form-group", - label { "Search Type: " } - select { - class: "form-control", - value: "{search_type}", - onchange: move |evt| search_type.set(evt.value()), - option { value: "fulltext", "Full Text Search" } - option { value: "filename", "Filename Search" } - option { value: "duplicates", "Find Duplicate Files" } - } - } - - if search_type() == "fulltext" { - div { - class: "form-group", - style: "display: flex; flex-direction: column; gap: 6px;", - span { style: "font-weight: 600;", "Full text options" } - label { - style: "display: flex; align-items: center; gap: 8px; cursor: pointer;", - input { - r#type: "checkbox", - checked: fulltext_exact(), - onchange: move |evt| fulltext_exact.set(evt.checked()), - } - "Exact phrase match" - } - label { - style: "display: flex; align-items: center; gap: 8px; cursor: pointer;", - input { - r#type: "checkbox", - checked: fulltext_case_sensitive(), - onchange: move |evt| fulltext_case_sensitive.set(evt.checked()), - } - "Case-sensitive match" - } - } - } - - if search_type() != "duplicates" { - div { - class: "form-group", - label { "Search Term: " } - input { - class: "form-control", - r#type: "text", - value: "{search_term}", - oninput: move |evt| search_term.set(evt.value()), - onkeydown: { - let do_fresh_search = do_fresh_search.clone(); - move |evt| { - if evt.code() == dioxus::events::Code::Enter { - do_fresh_search(); - } - } - } - } - } - } - - div { - style: "display: flex; align-items: center; gap: 10px;", - button { - class: "btn btn-info", - disabled: is_searching(), - onclick: { - let do_fresh_search = do_fresh_search.clone(); - move |_| { do_fresh_search(); } - }, - "Search" - } - - if is_searching() { - div { - class: "loading", - title: "Searching..." - } - } else if let Some(elapsed) = last_search_time() { - span { - style: "color: #666; font-size: 0.9em;", - "Search completed in {elapsed:.3}s" - } - } - } - - if let Some(error) = search_error() { - div { - class: "error-message", - "Error: {error}" - } - } - - // Results panel: pagination header + bounded table. Only renders - // when at least one search has completed (last_args is Some). - if last_args().is_some() { - div { - class: "search-results", - { - let total_str = match total_count() { - Some(n) => format!("{}", n), - None => "?".to_string(), - }; - let page_first = (current_page().saturating_sub(1) as u64) * PAGE_SIZE as u64 + 1; - let page_last_calc = page_first + search_results().first().map(|r| r.rows.len() as u64).unwrap_or(0).saturating_sub(1); - let header = if total_count() == Some(0) { - "No results.".to_string() - } else { - format!( - "Showing {}-{} of {} (page {} of {})", - page_first, - page_last_calc, - total_str, - current_page(), - total_pages - ) - }; - rsx! { h3 { "{header}" } } - } - - // Pagination controls. Hidden if there's only one page. - if total_pages > 1 { - div { - style: "display: flex; align-items: center; gap: 8px; margin: 8px 0;", - button { - class: "btn", - disabled: is_searching() || current_page() <= 1, - onclick: { - let do_goto_page = do_goto_page.clone(); - move |_| do_goto_page(1) - }, - "« First" - } - button { - class: "btn", - disabled: is_searching() || current_page() <= 1, - onclick: { - let do_goto_page = do_goto_page.clone(); - move |_| do_goto_page(current_page().saturating_sub(1)) - }, - "‹ Prev" - } - button { - class: "btn", - disabled: is_searching() || current_page() >= total_pages, - onclick: { - let do_goto_page = do_goto_page.clone(); - move |_| do_goto_page(current_page().saturating_add(1)) - }, - "Next ›" - } - button { - class: "btn", - disabled: is_searching() || current_page() >= total_pages, - onclick: { - let do_goto_page = do_goto_page.clone(); - move |_| do_goto_page(total_pages) - }, - "Last »" - } - span { "Go to:" } - input { - r#type: "number", - style: "width: 70px;", - value: "{goto_input}", - oninput: move |evt| goto_input.set(evt.value()), - onkeydown: { - let do_goto_page = do_goto_page.clone(); - move |evt| { - if evt.code() == dioxus::events::Code::Enter { - if let Ok(p) = goto_input().trim().parse::() { - do_goto_page(p); - goto_input.set(String::new()); - } - } - } - } - } - } - } - - if !search_results().is_empty() && !search_results()[0].rows.is_empty() { - div { - class: "results-table", - table { - thead { - tr { - for column in search_results()[0].columns.iter() { - th { "{column}" } - } - } - } - tbody { - for row in search_results()[0].rows.iter() { - tr { - for (col_index, value) in row.values.iter().enumerate() { - if search_results()[0].columns.get(col_index).map(|s| s.as_str()) == Some("path") { - { - let value_owned = value.clone(); - let service_owned = props.indexing_service.clone(); - rsx! { - td { - class: "path-cell clickable", - onclick: move |_| { - let path = value_owned.clone(); - let service_clone = service_owned.clone(); - spawn(async move { - if let Err(e) = service_clone.open_file_explorer(&path) { - eprintln!("Failed to open file explorer: {}", e); - } - }); - }, - title: "Click to open in file explorer", - dangerous_inner_html: "{value}" - } - } - } - } else { - td { - dangerous_inner_html: "{value}" - } - } - } - } - } - } - } - } - } - } - } - - if show_corruption_dialog() { - div { - class: "modal-backdrop", - div { - class: "modal-dialog", - h3 { - style: "margin-top: 0; color: #d32f2f;", - "⚠️ Database Corruption Detected" - } - p { - style: "margin: 15px 0;", - "The database appears to be corrupted or malformed. This can happen due to unexpected shutdowns or disk issues." - } - p { - style: "margin: 15px 0; font-weight: bold;", - "Would you like to delete the corrupted database and create a new one? This will require re-indexing your files." - } - div { - style: "display: flex; gap: 10px; margin-top: 20px;", - button { - style: "padding: 10px 20px; background-color: #d32f2f; color: white; border: none; border-radius: 5px; cursor: pointer;", - onclick: move |_| { - let service = props.indexing_service.clone(); - let db = props.db_path.clone(); - - show_corruption_dialog.set(false); - search_error.set(Some("Deleting corrupted database...".to_string())); - - spawn(async move { - match service.delete_index_for_rebuild(&db) { - Ok(()) => { - search_error.set(Some("Database deleted. You can now start indexing again.".to_string())); - } - Err(e) => { - search_error.set(Some(format!("Error deleting database: {}", e))); - } - } - }); - }, - "Yes, Delete & Rebuild" - } - button { - style: "padding: 10px 20px; background-color: #666; color: white; border: none; border-radius: 5px; cursor: pointer;", - onclick: move |_| { - show_corruption_dialog.set(false); - }, - "Cancel" - } - } - } - } - } - } - } -} - -/// Number of pages required to fit `total` rows at [`PAGE_SIZE`] per page. -/// Returns 0 for 0 rows so callers can branch on "no results yet". -fn pages_for(total: u64) -> u32 { - if total == 0 { - 0 - } else { - ((total - 1) / PAGE_SIZE as u64) as u32 + 1 - } -} - -/// Centralized error-router for the two query branches that can fail -/// identically (count and select). Sets the error/timing/spinner signals -/// and pops the corruption dialog when warranted. -fn handle_query_error( - e: String, - elapsed: f64, - mut search_error: Signal>, - mut show_corruption_dialog: Signal, - mut last_search_time: Signal>, - mut is_searching: Signal, -) { - if e.starts_with("DATABASE_CORRUPTED:") { - search_error.set(Some("Database appears to be corrupted".into())); - show_corruption_dialog.set(true); - } else { - search_error.set(Some(e)); - } - last_search_time.set(Some(elapsed)); - is_searching.set(false); -} diff --git a/crates/quicksearch-gui/src/search_tab.rs b/crates/quicksearch-gui/src/search_tab.rs new file mode 100644 index 0000000..591a4f4 --- /dev/null +++ b/crates/quicksearch-gui/src/search_tab.rs @@ -0,0 +1,947 @@ +//! The Search tab: query strip, streaming results table, snippet +//! preview, context menu, ignore-filter dialog, and syntax help. + +use std::time::Instant; + +use egui::text::{LayoutJob, TextFormat}; +use egui_extras::{Column, TableBuilder}; +use quicksearch_core::search::{SearchHit, SearchUpdate}; +use quicksearch_core::snippet::Snippet; + +use crate::format::{fmt_elapsed, fmt_mtime, human_size}; +use crate::platform; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortKey { + Rank, + Name, + Path, + Size, + Modified, +} + +pub struct IgnoreDialog { + pub source_name: String, + pub source_path: String, + pub pattern: String, + pub persist: bool, +} + +/// What the tab asks the app to do after this frame. +#[derive(Default)] +pub struct SearchActions { + /// Re-run the search (query/fuzzy/session filters changed *now*, not + /// debounced — e.g. a chip was removed). + pub rerun: bool, + /// Persist an ignore pattern into the config. + pub persist_ignore: Option, + /// The fuzzy toggle changed; remember it in the config. + pub save_fuzzy_default: Option, +} + +pub struct SearchTab { + pub query: String, + pub fuzzy: bool, + /// Set on every edit; the app fires the search after the debounce. + pub pending_edit: Option, + pub generation: u64, + pub results: Vec, + /// The next search's hits, held back while the old table fades out; + /// swapped into `results` at zero opacity. Prevents the empty-refill + /// strobe while typing. + staging: Vec, + staging_has_snippets: bool, + /// True from search start until the staged set has been swapped in. + swap_pending: bool, + /// Display permutation over `results`. + order: Vec, + sort: (SortKey, bool), + sort_dirty: bool, + pub selected: Option, + pub running: bool, + /// When the in-flight search was submitted. + search_started: Option, + /// Wall time of the last completed search (all cascade passes). + elapsed: Option, + pub limited: bool, + pub error: Option, + pub session_ignores: Vec, + pub ignore_dialog: Option, + pub help_open: bool, + has_snippets: bool, + focus_query: bool, + /// Query syntax-highlight segments, cached per text. + highlight: crate::query_highlight::HighlightCache, +} + +impl SearchTab { + pub fn new(fuzzy_default: bool) -> SearchTab { + SearchTab { + query: String::new(), + fuzzy: fuzzy_default, + pending_edit: None, + generation: 0, + results: Vec::new(), + staging: Vec::new(), + staging_has_snippets: false, + swap_pending: false, + order: Vec::new(), + sort: (SortKey::Rank, true), + sort_dirty: false, + selected: None, + running: false, + search_started: None, + elapsed: None, + limited: false, + error: None, + session_ignores: Vec::new(), + ignore_dialog: None, + help_open: false, + has_snippets: false, + focus_query: true, + highlight: Default::default(), + } + } + + /// Pre-fill the query and let the normal debounce path run it, so a + /// command-line query lands the user on results rather than an empty box. + pub fn seed(&mut self, query: String) { + self.query = query; + self.pending_edit = Some(Instant::now()); + } + + /// A new search was submitted under `generation`. The previous + /// results stay on screen (fading out); the new ones stage until the + /// fade reaches zero. + pub fn on_search_started(&mut self, generation: u64) { + self.generation = generation; + self.staging.clear(); + self.staging_has_snippets = false; + self.swap_pending = true; + self.running = true; + self.search_started = Some(Instant::now()); + self.elapsed = None; + self.limited = false; + self.error = None; + } + + pub fn apply_update(&mut self, update: SearchUpdate, display_limit: usize) { + if update.generation() != self.generation { + return; + } + match update { + SearchUpdate::Started { .. } => {} + SearchUpdate::Hits { hits, .. } => { + if self.swap_pending { + // Old results are still fading out; hold the new ones. + for hit in hits { + if self.staging.len() >= display_limit { + self.limited = true; + break; + } + self.staging_has_snippets |= hit.snippet.is_some(); + self.staging.push(hit); + } + } else { + // Post-swap stream: later cascade passes append live. + for hit in hits { + if self.results.len() >= display_limit { + self.limited = true; + break; + } + self.has_snippets |= hit.snippet.is_some(); + self.results.push(hit); + } + // Arrival order *is* rank order, so the default sort + // needs no work; anything else re-sorts on the set. + if self.sort != (SortKey::Rank, true) { + self.sort_dirty = true; + } else { + self.order = (0..self.results.len() as u32).collect(); + } + } + } + SearchUpdate::Completed { limited, .. } => { + self.running = false; + self.elapsed = self.search_started.map(|t| t.elapsed()); + self.limited |= limited; + } + SearchUpdate::Error { message, .. } => { + self.running = false; + self.elapsed = self.search_started.map(|t| t.elapsed()); + self.error = Some(message); + } + } + } + + pub fn result_count_label(&self) -> Option { + if self.query.trim().is_empty() && self.results.is_empty() { + return None; + } + Some(if self.limited { + format!("{}+ results (truncated)", self.results.len()) + } else { + format!("{} results", self.results.len()) + }) + } + + fn resort(&mut self) { + let (key, ascending) = self.sort; + let selected_id = self + .selected + .and_then(|i| self.results.get(i as usize)) + .map(|h| h.file_id); + self.order = (0..self.results.len() as u32).collect(); + let results = &self.results; + self.order.sort_by(|&a, &b| { + let (a, b) = (&results[a as usize], &results[b as usize]); + let ord = match key { + SortKey::Rank => a.rank.partial_cmp(&b.rank).unwrap_or(std::cmp::Ordering::Equal), + SortKey::Name => a.name.cmp(&b.name), + SortKey::Path => a.path.cmp(&b.path), + SortKey::Size => a.size.cmp(&b.size), + SortKey::Modified => a.mtime.cmp(&b.mtime), + }; + if ascending { + ord + } else { + ord.reverse() + } + }); + // Selection follows the file, not the visual slot. + self.selected = selected_id.and_then(|id| { + self.results + .iter() + .position(|h| h.file_id == id) + .map(|i| i as u32) + }); + self.sort_dirty = false; + } + + /// A sortable column header: the whole cell is the click target, the + /// label is centered, and the sort indicator is a painter-drawn + /// triangle (the default egui fonts have no ▲/▼ glyphs — they render + /// as boxes). + fn sort_header(&mut self, ui: &mut egui::Ui, key: SortKey, label: &str) { + let (cur, asc) = self.sort; + let selected = cur == key; + let (rect, response) = + ui.allocate_exact_size(ui.available_size(), egui::Sense::click()); + if ui.is_rect_visible(rect) { + if response.hovered() { + ui.painter() + .rect_filled(rect, 2.0, ui.visuals().widgets.hovered.weak_bg_fill); + } + let font_id = egui::TextStyle::Body.resolve(ui.style()); + let color = ui.visuals().strong_text_color(); + let galley = ui + .painter() + .layout_no_wrap(label.to_string(), font_id, color); + let text_size = galley.size(); + let arrow_space = if selected { 11.0 } else { 0.0 }; + let text_pos = egui::pos2( + rect.center().x - (text_size.x + arrow_space) / 2.0, + rect.center().y - text_size.y / 2.0, + ); + ui.painter().galley(text_pos, galley, color); + if selected { + let cx = text_pos.x + text_size.x + 7.0; + let cy = rect.center().y; + let (w, h) = (3.5, 3.0); + let points = if asc { + vec![ + egui::pos2(cx, cy - h), + egui::pos2(cx - w, cy + h), + egui::pos2(cx + w, cy + h), + ] + } else { + vec![ + egui::pos2(cx, cy + h), + egui::pos2(cx - w, cy - h), + egui::pos2(cx + w, cy - h), + ] + }; + ui.painter().add(egui::Shape::convex_polygon( + points, + color, + egui::Stroke::NONE, + )); + } + } + if response.clicked() { + self.sort = if selected { (key, !asc) } else { (key, true) }; + self.sort_dirty = true; + } + } + + pub fn ui(&mut self, ui: &mut egui::Ui) -> SearchActions { + let mut actions = SearchActions::default(); + + // --- Query strip ------------------------------------------------- + ui.horizontal(|ui| { + let show_elapsed = + !self.running && self.elapsed.is_some() && !self.query.trim().is_empty(); + let slot_room = if self.running { + 24.0 + } else if show_elapsed { + 60.0 + } else { + 0.0 + }; + let width = ui.available_width() - 170.0 - slot_room; + let highlight = &mut self.highlight; + let mut layouter = move |ui: &egui::Ui, buf: &dyn egui::TextBuffer, _wrap: f32| { + crate::query_highlight::galley(ui, highlight, buf.as_str()) + }; + let response = ui.add( + egui::TextEdit::singleline(&mut self.query) + .desired_width(width.max(120.0)) + .hint_text("Search names and contents… (type:Document regex:… budget*)") + .layouter(&mut layouter), + ); + if self.focus_query { + response.request_focus(); + self.focus_query = false; + } + if response.changed() { + self.pending_edit = Some(Instant::now()); + } + // One slot right of the box: spinner while searching, then the + // total wall time of all cascade passes once it lands. + if self.running { + ui.add(egui::Spinner::new().size(16.0)); + } else if show_elapsed { + if let Some(elapsed) = self.elapsed { + ui.label(egui::RichText::new(fmt_elapsed(elapsed)).small().weak()) + .on_hover_text("Time to run all search passes"); + } + } + if ui + .checkbox(&mut self.fuzzy, "Fuzzy") + .on_hover_text("Also run fuzzy filename and full-text passes (slower)") + .changed() + { + actions.save_fuzzy_default = Some(self.fuzzy); + actions.rerun = true; + } + if ui.button("?").on_hover_text("Query syntax help").clicked() { + self.help_open = !self.help_open; + } + }); + + // Session ignore chips. + if !self.session_ignores.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("Ignoring:").small().weak()); + let mut remove: Option = None; + for (i, pattern) in self.session_ignores.iter().enumerate() { + if ui + .small_button(format!("{} ✕", pattern)) + .on_hover_text("Remove this session filter") + .clicked() + { + remove = Some(i); + } + } + if let Some(i) = remove { + self.session_ignores.remove(i); + actions.rerun = true; + } + }); + } + + // Notices. + if let Some(err) = &self.error { + ui.colored_label(ui.visuals().error_fg_color, err); + } else if self.limited { + ui.label( + egui::RichText::new(format!( + "Showing first {} matches; refine the query (limit configurable in Options).", + self.results.len() + )) + .small() + .weak(), + ); + } else if !self.running + && !self.swap_pending + && self.results.is_empty() + && !self.query.trim().is_empty() + && self.error.is_none() + { + ui.label(egui::RichText::new("No results.").small().weak()); + } + + // Result-set transitions pulse instead of strobing: the old table + // fades out over 0.25 s while the new hits stage, the sets swap at + // zero opacity, and the new table fades back in over 0.25 s. + // `animate_value_with_time` keeps requesting repaints until the + // value settles. + let fade_target = if self.swap_pending { 0.0 } else { 1.0 }; + let fade = ui.ctx().animate_value_with_time( + egui::Id::new("qs-results-fade"), + fade_target, + 0.25, + ); + if self.swap_pending && fade <= 0.01 { + self.results = std::mem::take(&mut self.staging); + self.has_snippets = self.staging_has_snippets; + self.selected = None; + self.swap_pending = false; + if self.sort == (SortKey::Rank, true) { + self.order = (0..self.results.len() as u32).collect(); + } else { + self.sort_dirty = true; + } + } + + if self.sort_dirty { + self.resort(); + } + + // Fade covers the table and the preview strip below it; the modal + // windows and notices render at full opacity on their own layers. + ui.set_opacity(fade); + + // --- Results table ------------------------------------------------ + // Reserve room for the selected-row snippet preview strip. Only + // content matches get one — a filename match's "snippet" is the + // name, already on screen. + let preview_snippet: Option = self + .selected + .and_then(|i| self.results.get(i as usize)) + .filter(|h| matches!(h.stage, 5 | 6 | 8)) + .and_then(|h| h.snippet.clone()); + let preview_height = if preview_snippet.is_some() { 44.0 } else { 0.0 }; + let table_height = (ui.available_height() - preview_height).max(60.0); + + let text_height = egui::TextStyle::Body.resolve(ui.style()).size + 4.0; + let mut open_ignore_dialog: Option = None; + + ui.push_id("results", |ui| { + let mut table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .sense(egui::Sense::click()) + .max_scroll_height(table_height) + .min_scrolled_height(60.0) + .column(Column::initial(220.0).at_least(80.0).clip(true)) // name + .column(Column::remainder().at_least(120.0).clip(true)); // path + if self.has_snippets { + table = table.column(Column::remainder().at_least(120.0).clip(true)); + } + table = table + .column(Column::exact(72.0)) // size + .column(Column::exact(110.0)) // modified + .column(Column::exact(52.0)); // rank + + table + .header(text_height + 4.0, |mut header| { + header.col(|ui| self.sort_header(ui, SortKey::Name, "Name")); + header.col(|ui| self.sort_header(ui, SortKey::Path, "Path")); + if self.has_snippets { + header.col(|ui| { + ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label(egui::RichText::new("Match").strong()); + }, + ); + }); + } + header.col(|ui| self.sort_header(ui, SortKey::Size, "Size")); + header.col(|ui| self.sort_header(ui, SortKey::Modified, "Modified")); + header.col(|ui| self.sort_header(ui, SortKey::Rank, "Rank")); + }) + .body(|body| { + let order = self.order.clone(); + body.rows(text_height, order.len(), |mut row| { + let result_ix = order[row.index()] as usize; + let hit = &self.results[result_ix]; + row.set_selected(self.selected == Some(result_ix as u32)); + + row.col(|ui| { + ui.label(&hit.name); + }); + row.col(|ui| { + ui.label(egui::RichText::new(&hit.path).weak()); + }); + if self.has_snippets { + let snippet = hit.snippet.clone(); + // Name and path matches show a whole field, so + // they render bracketed: [matched field]. + let whole_field = + hit.stage <= 4 || hit.stage == 7 || hit.stage >= 9; + row.col(|ui| { + if let Some(snip) = &snippet { + let width = ui.available_width(); + let job = centered_match_job(ui, snip, width, whole_field); + let response = ui + .with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| ui.label(job), + ) + .inner; + if !snip.ranges.is_empty() { + let hover = snip.clone(); + response.on_hover_ui(|ui| { + ui.set_max_width(520.0); + let job = snippet_job(ui, &hover, 10); + ui.label(job); + }); + } + } + }); + } + row.col(|ui| { + ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label(human_size(hit.size)); + }, + ); + }); + row.col(|ui| { + let color = recency_color(ui, hit.mtime); + ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label( + egui::RichText::new(fmt_mtime(hit.mtime)).color(color), + ); + }, + ); + }); + row.col(|ui| { + ui.with_layout( + egui::Layout::centered_and_justified( + egui::Direction::LeftToRight, + ), + |ui| { + ui.label( + egui::RichText::new(format!(" {:.2} ", hit.rank)) + .background_color(rank_tier_color(hit.stage)) + .color(egui::Color32::from_rgb(32, 32, 32)), + ); + }, + ); + }); + + let response = row.response(); + if response.clicked() { + self.selected = Some(result_ix as u32); + } + if response.double_clicked() { + platform::open_file(&self.results[result_ix].path); + } + response.context_menu(|ui| { + let path = self.results[result_ix].path.clone(); + if ui.button("Open").clicked() { + platform::open_file(&path); + ui.close(); + } + if ui.button("Open containing folder").clicked() { + platform::reveal_in_folder(&path); + ui.close(); + } + ui.separator(); + if ui.button("Build ignore filter…").clicked() { + open_ignore_dialog = Some(result_ix); + ui.close(); + } + }); + }); + }); + }); + + if let Some(ix) = open_ignore_dialog { + let hit = &self.results[ix]; + self.ignore_dialog = Some(IgnoreDialog { + source_name: hit.name.clone(), + source_path: hit.path.clone(), + pattern: hit.name.clone(), + persist: false, + }); + } + + // Selected-row preview strip: the full snippet, wrapped. + if let Some(snip) = &preview_snippet { + ui.separator(); + let job = snippet_job(ui, snip, 2); + ui.label(job); + } + + self.ignore_dialog_ui(ui.ctx(), &mut actions); + self.help_window_ui(ui.ctx()); + actions + } + + fn ignore_dialog_ui(&mut self, ctx: &egui::Context, actions: &mut SearchActions) { + let Some(dialog) = &mut self.ignore_dialog else { + return; + }; + let mut apply = false; + let mut cancel = false; + egui::Window::new("Ignore filter") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(format!("From: {}", dialog.source_path)); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("This name").clicked() { + dialog.pattern = dialog.source_name.clone(); + } + if let Some(ext) = std::path::Path::new(&dialog.source_name) + .extension() + .and_then(|e| e.to_str()) + { + if ui.button(format!("*.{}", ext)).clicked() { + dialog.pattern = format!("*.{}", ext); + } + } + if let Some(parent) = std::path::Path::new(&dialog.source_path) + .parent() + .and_then(|p| p.to_str()) + { + if ui.button("This directory").clicked() { + dialog.pattern = format!("{}/*", parent); + } + } + }); + ui.add( + egui::TextEdit::singleline(&mut dialog.pattern) + .desired_width(360.0) + .hint_text("glob pattern"), + ); + ui.checkbox(&mut dialog.persist, "Persist to config"); + ui.label( + egui::RichText::new( + "Session filters hide results immediately. Persisted filters also \ + exclude files from the index at the next reindex.", + ) + .small() + .weak(), + ); + ui.horizontal(|ui| { + if ui.button("Apply").clicked() { + apply = true; + } + if ui.button("Cancel").clicked() { + cancel = true; + } + }); + }); + if apply { + let dialog = self.ignore_dialog.take().unwrap(); + let pattern = dialog.pattern.trim().to_string(); + if !pattern.is_empty() { + if !self.session_ignores.contains(&pattern) { + self.session_ignores.push(pattern.clone()); + } + if dialog.persist { + actions.persist_ignore = Some(pattern); + } + actions.rerun = true; + } + } else if cancel { + self.ignore_dialog = None; + } + } + + fn help_window_ui(&mut self, ctx: &egui::Context) { + let mut open = self.help_open; + egui::Window::new("Query syntax") + .open(&mut open) + .resizable(false) + .default_width(540.0) + .show(ctx, |ui| { + ui.label( + "Everything that is not a filter is matched as one phrase, in order. \ + Filters combine freely with the search text.", + ); + ui.add_space(6.0); + egui::Grid::new("query-syntax-table") + .num_columns(2) + .spacing([18.0, 5.0]) + .striped(true) + .show(ui, |ui| { + let row = |ui: &mut egui::Ui, syntax: &str, meaning: &str| { + ui.monospace(syntax); + ui.label(meaning); + ui.end_row(); + }; + row( + ui, + "budget report", + "names, contents, and paths containing \"budget report\"", + ); + row( + ui, + "\"exact phrase\"", + "quotes keep spaces, stars, and filter-like words literal", + ); + row( + ui, + "bud*report", + "* matches any run of characters (within a line); \ + also works in name: values", + ); + row( + ui, + "regex:\"(foo|bar)\\d+\"", + "regular expression, matched against names, contents, \ + and paths; case-insensitive — use (?-i:…) to override; \ + quote patterns containing spaces", + ); + row( + ui, + "type:Document", + "file class: Audio, Image, Video, Document, Text, \ + Archive, Spreadsheet, Presentation, Folder", + ); + row( + ui, + "modified:>=2024-01-01", + "modification date (yyyy-mm-dd); also <, <=, > and =", + ); + row( + ui, + "path:/home/me/docs", + "only results in that folder and its subfolders; \ + quote paths containing spaces", + ); + row(ui, "mime:application/pdf", "exact MIME type"); + row( + ui, + "name:report", + "filename contains, applied as an unranked filter", + ); + }); + ui.add_space(6.0); + ui.label("Example:"); + ui.monospace("type:Document modified:>=2024-01-01 quarterly budget"); + ui.add_space(6.0); + ui.label( + egui::RichText::new( + "Ranking: exact filename matches, then filename substrings, then \ + full-text matches (ordered by occurrences), then fuzzy matches \ + when enabled, and finally matches on the rest of the file path.", + ) + .small() + .weak(), + ); + ui.label( + egui::RichText::new( + "The complete reference, including ranking details and the \ + fuzzy edit budget, is the \"Query syntax\" section of \ + README.md in the QuickSearch folder.", + ) + .small() + .weak(), + ); + }); + self.help_open = open; + } +} + +struct SnippetFormats { + normal: TextFormat, + highlight: TextFormat, + weak: TextFormat, +} + +fn snippet_formats(ui: &egui::Ui) -> SnippetFormats { + let font_id = egui::TextStyle::Body.resolve(ui.style()); + SnippetFormats { + normal: TextFormat { + font_id: font_id.clone(), + color: ui.visuals().text_color(), + ..Default::default() + }, + highlight: TextFormat { + font_id: font_id.clone(), + color: ui.visuals().strong_text_color(), + background: ui.visuals().selection.bg_fill.gamma_multiply(0.4), + ..Default::default() + }, + weak: TextFormat { + font_id, + color: ui.visuals().weak_text_color(), + ..Default::default() + }, + } +} + +/// Build a highlighted snippet LayoutJob from byte ranges, wrapped to at +/// most `max_rows`. Cheap enough to run per visible row per frame. +fn snippet_job(ui: &egui::Ui, snip: &Snippet, max_rows: usize) -> LayoutJob { + let fmt = snippet_formats(ui); + let mut job = LayoutJob::default(); + job.wrap.max_rows = max_rows; + if max_rows == 1 { + job.wrap.break_anywhere = true; + } + if snip.truncated_start { + job.append("… ", 0.0, fmt.weak.clone()); + } + let mut cursor = 0; + for &(start, end) in &snip.ranges { + if start > cursor { + job.append(&snip.window[cursor..start], 0.0, fmt.normal.clone()); + } + job.append(&snip.window[start..end], 0.0, fmt.highlight.clone()); + cursor = end; + } + if cursor < snip.window.len() { + job.append(&snip.window[cursor..], 0.0, fmt.normal.clone()); + } + if snip.truncated_end { + job.append(" …", 0.0, fmt.weak); + } + job +} + +/// The Match column cell: one line with the (first) matched span centered +/// and an equal amount of context on both sides, trimmed to what fits the +/// column width. Matches on a whole field — a filename or a path — are +/// wrapped in brackets: `[name]`. +fn centered_match_job( + ui: &egui::Ui, + snip: &Snippet, + width_px: f32, + whole_field: bool, +) -> LayoutJob { + let fmt = snippet_formats(ui); + let font_id = egui::TextStyle::Body.resolve(ui.style()); + let char_width = ui.fonts(|f| f.glyph_width(&font_id, '0')).max(1.0); + let mut budget = ((width_px / char_width) as usize).saturating_sub(2).max(8); + if whole_field { + budget = budget.saturating_sub(2); // room for the brackets + } + + // Newlines force line breaks even in a one-row LayoutJob, wrecking the + // centered single-line cell. Flatten them to spaces — a byte-for-byte + // ASCII replacement, so the match ranges stay valid. The mouseover + // renders the original window untouched. + let flattened = snip.window.replace(['\n', '\r', '\t'], " "); + let window = flattened.as_str(); + let (start, end) = match snip.ranges.first().copied() { + Some((a, b)) => { + let match_chars = window[a..b].chars().count(); + let side = budget.saturating_sub(match_chars) / 2; + let before = &window[..a]; + let after = &window[b..]; + let before_count = before.chars().count(); + let after_count = after.chars().count(); + // Equal context on both sides; leftover budget from a short + // side flows to the other. + let take_before = (side + side.saturating_sub(after_count)).min(before_count); + let take_after = (side + side.saturating_sub(before_count)).min(after_count); + let start = if take_before == 0 { + a + } else { + before + .char_indices() + .nth_back(take_before - 1) + .map(|(i, _)| i) + .unwrap_or(0) + }; + let end = b + after + .char_indices() + .nth(take_after) + .map(|(i, _)| i) + .unwrap_or(after.len()); + (start, end) + } + None => { + // No ranges (shouldn't happen for match cells) — head trim. + let end = window + .char_indices() + .nth(budget) + .map(|(i, _)| i) + .unwrap_or(window.len()); + (0, end) + } + }; + + let mut job = LayoutJob::default(); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + if whole_field { + job.append("[", 0.0, fmt.weak.clone()); + } + if start > 0 || snip.truncated_start { + job.append("…", 0.0, fmt.weak.clone()); + } + let mut cursor = start; + for &(a, b) in &snip.ranges { + let (a, b) = (a.max(start), b.min(end)); + if a >= b || a >= end { + continue; + } + if a > cursor { + job.append(&window[cursor..a], 0.0, fmt.normal.clone()); + } + job.append(&window[a..b], 0.0, fmt.highlight.clone()); + cursor = b; + } + if cursor < end { + job.append(&window[cursor..end], 0.0, fmt.normal.clone()); + } + if end < window.len() || snip.truncated_end { + job.append("…", 0.0, fmt.weak.clone()); + } + if whole_field { + job.append("]", 0.0, fmt.weak); + } + job +} + +/// Tier-list chip color per cascade stage — lower rank, higher tier: +/// S-red for exact case-sensitive filename matches down through the +/// pastel ramp to purple for fuzzy full-text and on to the grey path +/// tiers. Dark text on these pastels stays readable in both themes. +fn rank_tier_color(stage: u8) -> egui::Color32 { + match stage { + 1 => egui::Color32::from_rgb(255, 127, 127), // S + 2 => egui::Color32::from_rgb(255, 191, 127), // A + 3 => egui::Color32::from_rgb(255, 223, 127), // B + 4 => egui::Color32::from_rgb(255, 255, 127), // C + 5 => egui::Color32::from_rgb(191, 255, 127), // D + 6 => egui::Color32::from_rgb(127, 255, 127), // E + 7 => egui::Color32::from_rgb(127, 191, 255), // F + 8 => egui::Color32::from_rgb(191, 127, 255), // G + 9 => egui::Color32::from_rgb(223, 159, 255), // H — path, exact case + 10 => egui::Color32::from_rgb(239, 191, 239), // I — path, any case + _ => egui::Color32::from_rgb(199, 199, 199), // J — fuzzy path + } +} + +/// Timestamp color: fresh files get a green tint that fades into the weak +/// text color over ~2 years on a log scale. +fn recency_color(ui: &egui::Ui, mtime: i64) -> egui::Color32 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let age_hours = ((now - mtime).max(0) as f32 / 3600.0).max(1.0); + const HORIZON_HOURS: f32 = 24.0 * 365.0 * 2.0; + let t = (age_hours.ln() / HORIZON_HOURS.ln()).clamp(0.0, 1.0); + let fresh = egui::Color32::from_rgb(87, 187, 122); + let old = ui.visuals().weak_text_color(); + let lerp = |a: u8, b: u8| (a as f32 + (b as f32 - a as f32) * t).round() as u8; + egui::Color32::from_rgb( + lerp(fresh.r(), old.r()), + lerp(fresh.g(), old.g()), + lerp(fresh.b(), old.b()), + ) +} diff --git a/crates/quicksearch-gui/src/tracker.rs b/crates/quicksearch-gui/src/tracker.rs new file mode 100644 index 0000000..0f612ba --- /dev/null +++ b/crates/quicksearch-gui/src/tracker.rs @@ -0,0 +1,144 @@ +//! Indexing-rate estimation for the status displays. +//! +//! The old tracker sampled the counter every poll tick but pruned to a +//! 1-second window, so anything slower than ~1 file/sec measured a +//! genuine zero and displayed "0.0 files/sec" despite progress. This one +//! records a point only when the counter *changes*, keeps up to 60 s of +//! history but never fewer than two points (so slow rates stay +//! computable), and measures against `now` so the estimate decays during +//! stalls instead of freezing at the last burst. + +use std::time::{Duration, Instant}; + +const HISTORY: Duration = Duration::from_secs(60); + +pub struct SpeedTracker { + /// (when, counter value) — appended only on counter change. + points: Vec<(Instant, usize)>, +} + +impl SpeedTracker { + pub fn new() -> SpeedTracker { + SpeedTracker { points: Vec::new() } + } + + /// Reset between phases (each phase restarts its counter). + pub fn reset(&mut self) { + self.points.clear(); + } + + pub fn record(&mut self, files_processed: usize) { + self.record_at(Instant::now(), files_processed); + } + + fn record_at(&mut self, now: Instant, files_processed: usize) { + match self.points.last() { + Some(&(_, last)) if last == files_processed => return, + // Counter went backwards — a new phase started without an + // explicit reset. + Some(&(_, last)) if files_processed < last => self.points.clear(), + _ => {} + } + self.points.push((now, files_processed)); + // Prune old points, but always keep at least two so a slow but + // steady rate never becomes unmeasurable. + while self.points.len() > 2 + && now.duration_since(self.points[0].0) > HISTORY + { + self.points.remove(0); + } + } + + /// Estimated files/sec, measured from the oldest retained progress + /// point to *now*. `None` until two data points exist. + pub fn files_per_sec(&self) -> Option { + self.files_per_sec_at(Instant::now()) + } + + fn files_per_sec_at(&self, now: Instant) -> Option { + let (t0, c0) = *self.points.first()?; + let (_, c1) = *self.points.last()?; + if self.points.len() < 2 { + return None; + } + let span = now.duration_since(t0).as_secs_f64(); + if span <= 0.0 { + return None; + } + Some((c1 - c0) as f64 / span) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn needs_two_points() { + let mut t = SpeedTracker::new(); + let base = Instant::now(); + assert!(t.files_per_sec_at(base).is_none()); + t.record_at(base, 10); + assert!(t.files_per_sec_at(base).is_none()); + } + + #[test] + fn slow_rate_is_measurable_not_zero() { + // One file every 2.5 s — the old 1 s window reported 0.0 here. + let mut t = SpeedTracker::new(); + let base = Instant::now(); + for i in 0..4 { + t.record_at(base + Duration::from_millis(2500 * i), 10 + i as usize); + } + let rate = t.files_per_sec_at(base + Duration::from_millis(7500)).unwrap(); + assert!((rate - 0.4).abs() < 0.01, "expected ~0.4/s, got {}", rate); + } + + #[test] + fn unchanged_counter_adds_no_points() { + let mut t = SpeedTracker::new(); + let base = Instant::now(); + for i in 0..100 { + t.record_at(base + Duration::from_millis(50 * i), 42); + } + assert_eq!(t.points.len(), 1, "only the first observation recorded"); + } + + #[test] + fn stall_decays_toward_zero() { + let mut t = SpeedTracker::new(); + let base = Instant::now(); + t.record_at(base, 0); + t.record_at(base + Duration::from_secs(1), 100); // 100/s burst + let just_after = t.files_per_sec_at(base + Duration::from_secs(1)).unwrap(); + let stalled = t.files_per_sec_at(base + Duration::from_secs(20)).unwrap(); + assert!(just_after > 90.0); + assert!(stalled < 6.0, "estimate must decay during a stall: {}", stalled); + } + + #[test] + fn pruning_keeps_at_least_two_points() { + let mut t = SpeedTracker::new(); + let base = Instant::now(); + t.record_at(base, 1); + t.record_at(base + Duration::from_secs(30), 2); + // Far beyond the history window; both points are older than 60 s + // relative to this record. + t.record_at(base + Duration::from_secs(300), 3); + assert!(t.points.len() >= 2); + assert!(t + .files_per_sec_at(base + Duration::from_secs(300)) + .is_some()); + } + + #[test] + fn counter_regression_resets() { + let mut t = SpeedTracker::new(); + let base = Instant::now(); + t.record_at(base, 500); + t.record_at(base + Duration::from_secs(1), 600); + // New phase restarts from a small number. + t.record_at(base + Duration::from_secs(2), 3); + assert_eq!(t.points.len(), 1); + } +} diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh new file mode 100644 index 0000000..ee92843 --- /dev/null +++ b/packaging/build-deb.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# +# Build a Debian package for QuickSearch. +# +# ./packaging/build-deb.sh build and package +# ./packaging/build-deb.sh --no-build package an existing release binary +# ./packaging/build-deb.sh --no-strip keep debug symbols (25 MB vs 20 MB) +# ./packaging/build-deb.sh -o /tmp/out write the .deb somewhere else +# +# Environment: DEB_REVISION (default 1), DEB_MAINTAINER, SOURCE_DATE_EPOCH. +# +# Deliberately does not use cargo-deb, debhelper, fakeroot or an SVG rasteriser: +# dpkg-deb and desktop-file-utils are the only tools required, and both are part +# of a standard Debian or Ubuntu install. + +set -euo pipefail +# Directories created along the way must be 0755, not whatever the caller's +# umask happens to be, or the package ships group-writable directories. +umask 022 + +readonly PKG=quicksearch +# The GUI binary and the console-subsystem terminal binary. Both ship: on Unix +# `quicksearch` does both jobs, but the README and the shared man page name +# `quicksearch-cli` too, so it has to exist wherever the docs are installed. +readonly BINARIES=(quicksearch quicksearch-cli) +readonly REPO_ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +readonly ICON_SRC="$REPO_ROOT/crates/quicksearch-gui/assets/icons" +readonly ICON_SVG="$ICON_SRC/quicksearch_icon.svg" + +do_build=1 +do_strip=1 +out_dir="$REPO_ROOT/dist" + +die() { printf 'build-deb: %s\n' "$*" >&2; exit 1; } +say() { printf '\033[1m==>\033[0m %s\n' "$*"; } + +while [ $# -gt 0 ]; do + case "$1" in + --no-build) do_build=0 ;; + --no-strip) do_strip=0 ;; + -o|--output-dir) shift; [ $# -gt 0 ] || die "--output-dir needs a path"; out_dir="$1" ;; + # Print the header comment block, however long it grows. + -h|--help) awk 'NR > 1 { if ($0 !~ /^#/) exit; sub(/^# ?/, ""); print }' "${BASH_SOURCE[0]}"; exit 0 ;; + *) die "unknown option: $1 (try --help)" ;; + esac + shift +done + +for tool in dpkg-deb dpkg desktop-file-validate objdump gzip; do + command -v "$tool" >/dev/null 2>&1 || die "missing required tool: $tool" +done +[ "$do_strip" -eq 0 ] || command -v strip >/dev/null 2>&1 || die "missing strip (install binutils, or pass --no-strip)" + +# Version comes from [workspace.package] so the package can never drift from the +# crate version. +version="$(sed -n '/^\[workspace\.package\]/,/^\[/{ s/^version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p }' "$REPO_ROOT/Cargo.toml")" +[ -n "$version" ] || die "could not read version from Cargo.toml" + +revision="${DEB_REVISION:-1}" +maintainer="${DEB_MAINTAINER:-Jeremy }" +arch="$(dpkg --print-architecture)" +deb_version="${version}-${revision}" +stage="$out_dir/${PKG}_${deb_version}_${arch}" +deb="$out_dir/${PKG}_${deb_version}_${arch}.deb" + +# ---------------------------------------------------------------- build ---- + +if [ "$do_build" -eq 1 ]; then + say "Building quicksearch $version (release)" + ( cd "$REPO_ROOT" && cargo build --release -p quicksearch-gui ) +fi + +for bin in "${BINARIES[@]}"; do + [ -x "$REPO_ROOT/target/release/$bin" ] \ + || die "no release binary at target/release/$bin (drop --no-build?)" +done +# Both binaries link the same crates, so either gives the same glibc floor. +readonly primary_binary="$REPO_ROOT/target/release/$PKG" +[ -f "$ICON_SVG" ] || die "no icon at $ICON_SVG" + +say "Validating desktop entry" +desktop-file-validate "$REPO_ROOT/packaging/$PKG.desktop" + +# --------------------------------------------------------------- stage ----- + +say "Staging $stage" +rm -rf "$stage" +mkdir -p "$stage" + +for bin in "${BINARIES[@]}"; do + install -Dm755 "$REPO_ROOT/target/release/$bin" "$stage/usr/bin/$bin" +done +install -Dm644 "$REPO_ROOT/packaging/$PKG.desktop" "$stage/usr/share/applications/$PKG.desktop" +install -Dm644 "$ICON_SVG" "$stage/usr/share/icons/hicolor/scalable/apps/$PKG.svg" +install -Dm644 "$REPO_ROOT/packaging/copyright" "$stage/usr/share/doc/$PKG/copyright" +install -Dm644 "$REPO_ROOT/config_example.toml" "$stage/usr/share/doc/$PKG/config_example.toml" +install -Dm644 "$REPO_ROOT/README.md" "$stage/usr/share/doc/$PKG/README.md" + +# The PNGs are committed next to the quicksearch_icon.svg they came from, so +# this script only copies them and an ordinary `cargo build` needs no image +# tooling. To re-render them after editing the SVG, build a throwaway crate +# (outside this workspace, to keep it out of Cargo.lock) depending on +# `resvg = { version = "0.45", default-features = false }` and, for each size N, +# parse with usvg::Tree::from_data, make a tiny_skia::Pixmap::new(N, N), call +# resvg::render with Transform::from_scale(N/240.0, N/240.0) and save_png. The +# SVG contains no , so no font support is needed. +# +# Sizes follow whatever is committed, so adding one needs no script change. +shopt -s nullglob +icons=("$ICON_SRC"/$PKG-*.png) +shopt -u nullglob +[ "${#icons[@]}" -gt 0 ] || die "no icons in $ICON_SRC" +for png in "${icons[@]}"; do + n="$(basename "$png" .png)"; n="${n#$PKG-}" + # The glob would also catch a non-size name like quicksearch-cli.png. + case "$n" in ''|*[!0-9]*) die "unexpected icon name: $(basename "$png")" ;; esac + install -Dm644 "$png" "$stage/usr/share/icons/hicolor/${n}x${n}/apps/$PKG.png" +done +say "Installed ${#icons[@]} icon sizes plus the scalable SVG" + +# Debian wants man pages and the changelog compressed, with no gzip timestamp so +# repeat builds are byte-identical. quicksearch-cli.1 is a one-line .so stub +# pointing at quicksearch.1, which documents both binaries. +install -dm755 "$stage/usr/share/man/man1" +for page in "${BINARIES[@]}"; do + gzip -9nc "$REPO_ROOT/packaging/$page.1" > "$stage/usr/share/man/man1/$page.1.gz" + chmod 644 "$stage/usr/share/man/man1/$page.1.gz" +done + +if [ -n "${SOURCE_DATE_EPOCH:-}" ]; then + changelog_date="$(date -R -u -d "@$SOURCE_DATE_EPOCH")" +else + changelog_date="$(date -R)" +fi +gzip -9nc < "$stage/usr/share/doc/$PKG/changelog.Debian.gz" +$PKG ($deb_version) unstable; urgency=medium + + * Package build of $PKG $version. + + -- $maintainer $changelog_date +EOF +chmod 644 "$stage/usr/share/doc/$PKG/changelog.Debian.gz" + +# No config.toml is installed anywhere. Config::config_path() treats a +# config.toml sitting next to the executable as portable mode and lets it +# override the per-user config outright, so one in /usr/bin would hijack every +# account on the machine. Config::load_from creates ~/.config/quicksearch/ +# config.toml on first run instead. + +if [ "$do_strip" -eq 1 ]; then + for bin in "${BINARIES[@]}"; do + before="$(du -h "$stage/usr/bin/$bin" | cut -f1)" + strip --strip-unneeded "$stage/usr/bin/$bin" + say "Stripped $bin: $before -> $(du -h "$stage/usr/bin/$bin" | cut -f1)" + done +fi + +installed_size="$(du -ks "$stage" | cut -f1)" + +# ------------------------------------------------------------- control ----- + +# The dynamic section only names libc, libgcc, libm and libbz2: winit and glutin +# dlopen the entire display stack, so dpkg-shlibdeps cannot see it and the list +# below is maintained by hand. Re-derive it with +# objdump -p target/release/quicksearch | grep NEEDED +# strings -a target/release/quicksearch | grep -oE 'lib[A-Za-z0-9_+-]+\.so(\.[0-9]+)*' | sort -u +# and map each soname to a package with `dpkg -S`. +glibc_min="$(objdump -T "$primary_binary" | sed -n 's/.*GLIBC_\([0-9][0-9.]*\).*/\1/p' | sort -V | tail -1)" +[ -n "$glibc_min" ] || die "could not determine the glibc version requirement" + +depends="libc6 (>= ${glibc_min}), libgcc-s1 (>= 3.0), libbz2-1.0" +depends="$depends, libx11-6, libxcb1, libxcursor1, libxi6, libxrender1" +depends="$depends, libxkbcommon0, libxkbcommon-x11-0" +depends="$depends, libwayland-client0, libwayland-egl1" +depends="$depends, libegl1, libgl1" +# Required by policy for anything installing into the hicolor theme; it also +# provides the dpkg trigger that refreshes the icon cache on install. +depends="$depends, hicolor-icon-theme" + +# desktop-file-utils owns the /usr/share/applications trigger; dbus-bin provides +# the dbus-send used by "reveal in folder"; xdg-utils provides the xdg-open +# fallback; xdg-desktop-portal backs the native folder picker. None are needed +# to search, so none are hard dependencies. +recommends="desktop-file-utils, xdg-utils, dbus-bin, xdg-desktop-portal" + +install -dm755 "$stage/DEBIAN" +cat > "$stage/DEBIAN/control" <" + prints ranked results and exits without starting the indexer, the file + watcher or any background thread. +EOF + +# No postinst/postrm: hicolor-icon-theme and desktop-file-utils declare +# interest-noawait on /usr/share/icons/hicolor and /usr/share/applications, so +# dpkg refreshes both caches on install and removal by itself. + +# --------------------------------------------------------------- build ----- + +say "Building $deb" +dpkg-deb --root-owner-group --build "$stage" "$deb" >/dev/null +rm -rf "$stage" + +echo +dpkg-deb --info "$deb" +echo +say "Done: $deb" +echo " install with: sudo apt install $deb" +echo " inspect with: dpkg-deb --contents $deb" diff --git a/packaging/copyright b/packaging/copyright new file mode 100644 index 0000000..e108773 --- /dev/null +++ b/packaging/copyright @@ -0,0 +1,61 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: quicksearch +Upstream-Contact: Jeremy +Source: https://code.karsttech.com/jeremy/quick_search.git + +Files: * +Copyright: 2025-2026 Jeremy +License: GPL-3.0-or-later + +Files: crates/quicksearch-gui/assets/icons/* +Copyright: 2026 Jeremy +License: GPL-3.0-or-later + +License: GPL-3.0-or-later + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see . + . + On Debian systems the full text of the GNU General Public License + version 3 can be found in /usr/share/common-licenses/GPL-3. + +Comment: + /usr/bin/quicksearch and /usr/bin/quicksearch-cli are statically linked Rust + binaries built from the same crates. Their third-party components are not + shipped as separate files, so they are summarised here + rather than given individual stanzas. Every one of the 512 crates in + Cargo.lock is under a permissive licence, all of which are compatible with + GPL-3.0-or-later: + . + * MIT and/or Apache-2.0 (the large majority) + * Unicode-3.0, BSD-2-Clause, BSD-3-Clause, ISC, Zlib, 0BSD, + BSL-1.0, CC0-1.0, Unlicense + . + There are no GPL-only, LGPL-only, MPL or proprietary dependencies. Note + that a dozen crates (winit, glutin, ab_glyph, owned_ttf_parser and + related) are Apache-2.0 only; Apache-2.0 is compatible with GPL version 3 + but not with GPL version 2, which is why this package is + GPL-3.0-or-later rather than GPL-2.0-or-later. + . + Bundled C sources compiled into the binary: SQLite (public domain, via + rusqlite), Zstandard (BSD-3-Clause arm of its BSD/GPL-2.0 dual licence, + via zstd-sys) and bzip2 (BSD-style, via bzip2-sys). + . + The binary also embeds egui's default fonts (crate epaint_default_fonts, + "(MIT OR Apache-2.0) AND OFL-1.1 AND Ubuntu-font-1.0"). The FSF regards + OFL-1.1 and the Ubuntu Font Licence as free but GPL-incompatible; they are + included here as font data rather than as linked program code, which is + the same basis on which Debian ships these fonts and other egui-based + applications. + . + Run `cargo metadata --all-features` against the source tree to reproduce + the per-crate licence list. diff --git a/packaging/quicksearch-cli.1 b/packaging/quicksearch-cli.1 new file mode 100644 index 0000000..6786a99 --- /dev/null +++ b/packaging/quicksearch-cli.1 @@ -0,0 +1,3 @@ +.\" quicksearch-cli is documented alongside quicksearch; this stub makes +.\" `man quicksearch-cli` resolve to that page. +.so man1/quicksearch.1 diff --git a/packaging/quicksearch.1 b/packaging/quicksearch.1 new file mode 100644 index 0000000..cefd9b7 --- /dev/null +++ b/packaging/quicksearch.1 @@ -0,0 +1,136 @@ +.TH QUICKSEARCH 1 "2026-08-02" "quicksearch 0.1.0" "User Commands" +.SH NAME +quicksearch, quicksearch\-cli \- fast full\-text search across your files +.SH SYNOPSIS +.B quicksearch +.br +.B quicksearch +.RI [ FLAGS ] +.IR "query terms" ... +.br +.B quicksearch\-cli +.RI [ FLAGS ] +.IR "query terms" ... +.SH DESCRIPTION +.B quicksearch +maintains a SQLite/FTS5 index of the paths listed in its configuration and +searches it by filename and file content. +.PP +Invoked with no positional arguments it opens the desktop application, which +owns the indexer: it walks the configured roots, watches them for changes and +periodically reindexes. +.PP +Invoked with one or more query terms it runs the same ranked search cascade the +application uses, prints the results and exits. Terminal mode never starts the +indexer, the filesystem watcher or any background thread, so a one\-shot query +neither leaves anything running nor competes for inotify watches with a running +application. It requires an index that already exists; run the application once +to build one. +.PP +.B quicksearch\-cli +is terminal search and nothing else, and accepts the same flags and query +syntax. On this platform it is equivalent to giving +.B quicksearch +a query, and exists so that scripts and documentation have one name that never +opens a window. It matters on Windows, where the two cannot be one executable: +a GUI built as a console application flashes a console window at every launch, +and a console tool built as a GUI application cannot write back to the shell +that invoked it. Given no query it prints usage and exits 2 rather than falling +back to the application. +.SH OPTIONS +.TP +.B \-\-fuzzy +Also run the fuzzy filename and full\-text passes, which tolerate spelling +differences at the cost of speed. The edit distance comes from +.I [search].fuzzy_max_edits +in the configuration. +.TP +.BI \-\-limit " N" +Return at most +.I N +results. Defaults to +.I [search].display_limit +in the configuration. +.B \-\-limit=N +is also accepted. +.TP +.B \-\-long +Print rank, size, modification time and a matching snippet for each hit instead +of bare paths. Highlights the match in bold when stdout is a terminal. +.TP +.BR \-h ", " \-\-help +Print usage and exit. +.PP +An unrecognised option given without any query terms is passed through and the +application is opened, since it may be an option for the windowing backend. +.SH QUERY SYNTAX +Plain words form a single phrase. Filters may be combined with it: +.TP +.B type:\fIName\fR +Match a file class, for example +.IR type:Document ", " type:Image ", " type:Audio . +.TP +.B modified:\fIexpr\fR +Compare against the modification date, for example +.IR modified:>=2024-01-01 . +.TP +.B path:\fI/dir\fR +Restrict results to a directory. +.TP +.B mime:\fItype\fR +Match a MIME type, for example +.IR mime:application/pdf . +.TP +.B name:\fIfragment\fR +Match a fragment of the filename. +.SH FILES +.TP +.I ~/.config/quicksearch/config.toml +Configuration. Created with defaults on first run. See +.I /usr/share/doc/quicksearch/config_example.toml +for the annotated reference. +.TP +.I ~/.local/share/quicksearch/index.sqlite +The index. The location is set by +.I [paths].database_path +in the configuration. +.TP +.I ./config.toml +A configuration file placed next to the +.B quicksearch +executable selects portable mode and overrides the per\-user configuration +entirely. The packaged build deliberately installs no such file. +.PP +.I XDG_CONFIG_HOME +and +.I XDG_DATA_HOME +are honoured when set to absolute paths. +.SH EXIT STATUS +.TP +.B 0 +The search completed, or +.B \-\-help +was requested. +.TP +.B 1 +The application failed to start. +.TP +.B 2 +The configuration could not be read, the index could not be opened, the query +was rejected, or +.B quicksearch\-cli +was given no query. +.SH EXAMPLES +.TP +Search for a phrase: +.B quicksearch quarterly revenue report +.TP +Recent PDFs under a directory, with details: +.B quicksearch \-\-long type:Document mime:application/pdf path:/home/me/docs modified:>=2026-01-01 +.TP +Tolerate misspellings and cap the output: +.B quicksearch \-\-fuzzy \-\-limit 10 recieve +.SH SEE ALSO +.I /usr/share/doc/quicksearch/README.md +.SH AUTHOR +Jeremy diff --git a/packaging/quicksearch.desktop b/packaging/quicksearch.desktop new file mode 100644 index 0000000..e0c579e --- /dev/null +++ b/packaging/quicksearch.desktop @@ -0,0 +1,19 @@ +[Desktop Entry] +Type=Application +Version=1.0 +Name=QuickSearch +GenericName=File Search +Comment=Fast full-text search across your files +Exec=quicksearch +TryExec=quicksearch +Icon=quicksearch +Terminal=false +# One main category only (Utility), so the app shows up exactly once in the +# menu; Filesystem is the additional category that describes what it does. +Categories=Utility;Filesystem; +Keywords=search;find;index;full-text;files;content; +StartupNotify=true +# Matches ViewportBuilder::with_app_id("quicksearch") in the GUI, which becomes +# the Wayland app id and the X11 WM_CLASS. Without the match the desktop shows a +# generic window icon. +StartupWMClass=quicksearch diff --git a/run.bat b/run.bat index c1e8007..9be5322 100644 --- a/run.bat +++ b/run.bat @@ -1 +1,11 @@ -cargo run -p quicksearch-gui +@echo off +rem Build and launch the QuickSearch GUI, mirroring run.sh. +rem Terminal search is a separate binary on Windows, because the GUI is built +rem as a window-subsystem app and cannot write to the calling shell: +rem target\release\quicksearch-cli.exe --help +setlocal +cargo build --release -p quicksearch-gui +if errorlevel 1 exit /b 1 +rem %~dp0 is this script's own directory (with a trailing backslash), so the +rem launch does not depend on the current working directory. +"%~dp0target\release\quicksearch.exe" %* diff --git a/run.sh b/run.sh index 7cef3e5..a9eb1e2 100644 --- a/run.sh +++ b/run.sh @@ -1,2 +1,7 @@ +#!/usr/bin/env sh +# Build and launch the QuickSearch GUI. On Unix the same binary doubles as the +# terminal search tool: ./target/release/quicksearch --help +# (On Windows that role belongs to quicksearch-cli.exe — see run.bat.) +set -e cargo build --release -p quicksearch-gui -./target/release/quicksearch +exec ./target/release/quicksearch "$@" diff --git a/setup.sh b/setup.sh deleted file mode 100644 index 98c20c8..0000000 --- a/setup.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -# One-time system setup for building and running the Dioxus-based -# `quicksearch-gui` binary on Debian / Ubuntu. The core library -# (`quicksearch-core`) has no native system deps and doesn't need this. -# -# Usage: -# ./setup.sh # installs everything -# -# Adds `pkg-config` and `build-essential` explicitly because the Dioxus -# build scripts (glib-sys, gdk-sys, gio-sys, javascriptcoregtk-sys, -# webkit2gtk-sys) shell out to `pkg-config` and will refuse to build -# without it. Some minimal Ubuntu flavors and container images don't -# install pkg-config transitively. -set -e - -sudo apt update -sudo apt install -y \ - pkg-config \ - build-essential \ - libsoup-3.0-dev \ - libjavascriptcoregtk-4.1-dev \ - libwebkit2gtk-4.1-dev \ - libxdo-dev