rust版本完成
This commit is contained in:
1 parent
eb3855f434
commit
8594a0167b
19 files changed
+7046
-63
No files matched your search
@@ -1,3 +1,5 @@
|
||||
python/build/
|
||||
.vscode/
|
||||
go/frontend/package-lock.json
|
||||
rust/target/
|
||||
go/.cache/
|
||||
+1
-1
@@ -279,7 +279,7 @@ excel-searcher -version
|
||||
| `EXCEL_SEARCH_HOST` | `127.0.0.1` | desktop/webui | HTTP 监听地址 |
|
||||
| `EXCEL_SEARCH_PORT` | `0`(随机) | desktop/webui | HTTP 端口 |
|
||||
| `EXCEL_SEARCH_WORKERS` | `8` | desktop/webui/searcher | 并发 worker 数(CLI 可用 `-workers` 覆盖) |
|
||||
| `EXCEL_SEARCH_TIMEOUT_SECONDS` | `30` | desktop/webui | 单次搜索超时 |
|
||||
| `EXCEL_SEARCH_TIMEOUT_SECONDS` | `600` | desktop/webui | 单次搜索超时 |
|
||||
| `EXCEL_SEARCH_LOG_DIR` | `logs` | desktop/webui | 日志目录 |
|
||||
| `EXCEL_SEARCH_LOG_FORMAT` | `text` | 全部 | 日志格式:`text` 或 `json` |
|
||||
| `EXCEL_SEARCH_CACHE_DIR` | `.cache` | 全部 | 搜索缓存目录 |
|
||||
|
||||
Generated
+4840
File diff suppressed because it is too large.
Load diff
+25
-2
@@ -2,16 +2,39 @@
|
||||
name = "excel-search-rust"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
description = "跨 Excel / Word / CSV / 文本 文件批量关键字检索工具(Rust 实现)"
|
||||
|
||||
[lib]
|
||||
name = "excel_search_rust"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "excel-search-rust"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "excel-search-rust-desktop"
|
||||
path = "src/desktop/main.rs"
|
||||
|
||||
[dependencies]
|
||||
calamine = "0.22"
|
||||
rayon = "1.8"
|
||||
walkdir = "2.4"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
zip = "0.6"
|
||||
clap = { version = "4.4", features = ["derive", "env"] }
|
||||
zip = { version = "0.6", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.31"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
# 匹配语义与 Go 版对齐所需:NFC 规范化与 Unicode 字母/数字/组合标记字符类判断
|
||||
unicode-normalization = "0.1"
|
||||
unicode_categories = "0.1"
|
||||
# 缓存文件名哈希(与 Go 版保持一致:sha256(绝对路径) 前 16 字节)
|
||||
sha2 = "0.10"
|
||||
eframe = "0.36.2"
|
||||
egui_extras = "0.36.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
+142
-45
@@ -1,58 +1,155 @@
|
||||
# Rust 实现方案与开发指南
|
||||
# Excel Keyword Search Tool — Rust 版
|
||||
|
||||
本文档为智能体(Agent)和开发者提供基于 Rust 语言重构 Excel 关键字搜索工具的详细设计、Cargo 配置和实施指南。
|
||||
跨 Excel / Word / CSV / 文本 文件批量关键字检索工具的 Rust 实现,包含两个交付物:
|
||||
|
||||
## 1. 架构设计目标
|
||||
- **极致的性能与最小体积**:利用 Rust 的内存安全、零成本抽象及编译优化(`opt-level = "z"`、`strip = true`),生成的独立二进制文件极小(通常在 5MB ~ 15MB 之间),运行速度达到极致。
|
||||
- **全格式 Excel 解析**:基于 `calamine` 库,完美支持 `.xlsx`, `.xls`, `.xlsb`, `.ods`。
|
||||
- **并行计算加速**:利用 `rayon` 库进行多核并行文件扫描与内容匹配。
|
||||
- **`excel-search-rust.exe`** — 命令行搜索器(对标 Go 版 `cmd/searcher`),追求极致的单二进制体积与启动速度;
|
||||
- **`excel-search-rust-desktop.exe`** — 桌面版(对标 Go 版 `cmd/desktop`):
|
||||
**原生 egui/eframe 图形界面**,单 exe 直接出窗口。不沿用 Go 版"HTTP 服务 + Edge 应用窗口"
|
||||
的前后端分离架构——那是受 Go 生态限制的妥协,Rust 有更好选择:
|
||||
关窗即退出(无心跳判活 hack)、搜索跑后台线程 UI 全程可交互(无 30s 服务端超时截断)、
|
||||
结果表原生虚拟滚动(万级命中不卡顿,无分页条)。
|
||||
|
||||
WebUI(独立浏览器标签页)形态仍由 Go 版提供(见 `../go/README.md`)。
|
||||
|
||||
## 特性
|
||||
|
||||
- **并发扫描**:rayon 线程池按文件并行解析,`--workers` 可配(默认 8,可用环境变量 `EXCEL_SEARCH_WORKERS` 覆盖)。
|
||||
- **匹配语义与 Go 版逐字节对齐**:
|
||||
- 关键字与文本均做 Unicode NFC 规范化(`cafe\u{301}` == `café`);
|
||||
- 默认不区分大小写(Unicode 折叠);
|
||||
- **完整单词匹配默认关闭**(模糊子串匹配);开启后要求关键字前后均为单词边界(字母/数字/组合标记/下划线之外)。
|
||||
- **文件格式**:
|
||||
- Excel:`.xlsx/.xlsm/.xltx/.xltm`,并借助 calamine 额外支持 `.xls/.xlsb/.ods`(Go 版不含这三种);
|
||||
- Word:`.docx/.docm`(解析 `word/document.xml`,按段落定位 `Paragraph N`,表格段落一并覆盖);
|
||||
- 文本:`.txt/.csv`(按行定位 `Line N`,CSV 不解析字段,整行视为纯文本)。
|
||||
- **文件级缓存**:与 Go 版相同的 JSON 结构(`{"version":2,"cache":{...}}`),
|
||||
按“路径 + 关键字 + ModTime + Size”判定命中;同一搜索根目录的缓存文件
|
||||
(`<cache_dir>/cache_kw_<sha256(绝对路径)前16字节>.json`)可与 Go 版互换读写。
|
||||
缓存目录由 `EXCEL_SEARCH_CACHE_DIR` 指定,默认运行目录下 `.cache/`。
|
||||
- **健壮性**:单文件解析失败仅向 stderr 记 `warn:` 告警并跳过,不中断整次搜索;
|
||||
stdout 只输出结果,`--json` 可安全被管道消费。
|
||||
|
||||
## 目录结构
|
||||
|
||||
## 2. 目录结构
|
||||
```text
|
||||
rust/
|
||||
├── Cargo.toml
|
||||
├── README.md
|
||||
├── build.rs
|
||||
└── src/
|
||||
├── main.rs
|
||||
├── error.rs
|
||||
└── engine/
|
||||
├── mod.rs
|
||||
├── walker.rs
|
||||
└── parser.rs
|
||||
├── src/
|
||||
│ ├── lib.rs # 库入口(engine + error)
|
||||
│ ├── main.rs # CLI(clap)
|
||||
│ ├── error.rs # 统一错误类型
|
||||
│ ├── history.rs # 搜索历史 LRU(与 Go 桌面版同格式共用文件)
|
||||
│ ├── desktop/
|
||||
│ │ └── main.rs # 桌面版:原生 egui GUI(eframe)
|
||||
│ └── engine/
|
||||
│ ├── mod.rs # 搜索编排:并行调度 + 缓存合并 + 选项/结果类型
|
||||
│ ├── matcher.rs # NFC + 大小写折叠 + 完整单词边界
|
||||
│ ├── walker.rs # 目录遍历与文件类型过滤
|
||||
│ ├── parser.rs # Excel(calamine) / Word(zip+quick-xml) / 文本 解析器
|
||||
│ └── cache.rs # 文件级 JSON 缓存(与 Go 版 schema 兼容)
|
||||
└── tests/
|
||||
├── fixtures/search_sample.xlsx # 真实 xlsx 夹具(Go excelize 生成)
|
||||
└── search.rs # 集成测试(与 Go scanner_test 断言语义对应)
|
||||
```
|
||||
|
||||
## 3. 核心配置与实现规范
|
||||
## 构建与测试
|
||||
|
||||
### 3.1 `Cargo.toml`(关键:体积与性能优化配置)
|
||||
```toml
|
||||
[package]
|
||||
name = "excel-search-rust"
|
||||
version = "1.0.0"
|
||||
edition = "2021"
|
||||
### 一键构建(Windows)
|
||||
|
||||
[dependencies]
|
||||
calamine = "0.22"
|
||||
rayon = "1.8"
|
||||
walkdir = "2.4"
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
zip = "0.6"
|
||||
quick-xml = "0.31"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
双击 `rust\build.bat`,或命令行:
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z" # 针对二进制文件大小进行优化
|
||||
lto = true # 链接时优化
|
||||
codegen-units = 1 # 单代码生成单元,最大化优化和压缩
|
||||
panic = "abort" # 移除栈展开开销
|
||||
strip = true # 剥离所有调试符号
|
||||
```bat
|
||||
build.bat :: release 构建(默认)
|
||||
build.bat debug :: debug 构建
|
||||
build.bat test :: 运行全部测试
|
||||
```
|
||||
|
||||
### 3.2 核心搜索逻辑要点 (`src/main.rs`)
|
||||
- 使用 `clap` 定义命令行参数(目录、关键字)。
|
||||
- 使用 `walkdir` 收集文件列表。
|
||||
- 使用 `rayon` 的 `par_iter()` 并行遍历所有文件:
|
||||
- 对 Excel 调用 `calamine::open_workbook_auto` 解析。
|
||||
- 对文本/Word 文件进行文本匹配。
|
||||
- 将匹配结果序列化为 JSON 或友好打印。
|
||||
脚本自动探测工具链(优先 mise 托管的 cargo/rustup → 系统 PATH),
|
||||
存在 GNU 工具链 + winlibs mingw 时自动走无 MSVC 编译路径,否则回退默认工具链并给出安装指引。
|
||||
|
||||
### 手动构建
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo test # 单元(lib 11 + 桌面 2)+ 集成(引擎端到端 10)
|
||||
cargo build --release # 产物(一次构建出两个 exe;LTO 全量构建约 2-20 分钟):
|
||||
# target/release/excel-search-rust.exe 命令行版 ≈1.3MB
|
||||
# target/release/excel-search-rust-desktop.exe 桌面版(原生 egui GUI)≈9.6MB
|
||||
```
|
||||
|
||||
**Windows 工具链说明**:本机若无 Visual Studio Build Tools / Windows SDK,
|
||||
默认 `x86_64-pc-windows-msvc` 目标无法链接,请改用 GNU 目标(winlibs 便携版即可,免安装):
|
||||
|
||||
```bash
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
# 将 winlibs-x86_64-posix-seh-*-ucrt 解压后将 mingw64\bin 加入 PATH,然后:
|
||||
cargo test --target x86_64-pc-windows-gnu
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
# 产物:target/x86_64-pc-windows-gnu/release/ 下的两个 exe
|
||||
```
|
||||
|
||||
## 用法
|
||||
|
||||
```bash
|
||||
# 基本用法(默认:不区分大小写 + 模糊匹配 + 递归子目录 + 全部文件类型)
|
||||
excel-search-rust --dir <目录> --keyword <关键字>
|
||||
excel-search-rust <目录> <关键字> # 等价的位置参数写法
|
||||
|
||||
# 输出 JSON(便于脚本处理)
|
||||
excel-search-rust --dir ./data --keyword 关键词 --json
|
||||
|
||||
# 区分大小写
|
||||
excel-search-rust --dir ./data --keyword Phone --case-sensitive=true
|
||||
|
||||
# 完整单词匹配(默认关闭,开启后要求关键字前后均为单词边界)
|
||||
excel-search-rust --dir ./data --keyword 手机 --whole-word
|
||||
|
||||
# 仅搜索根目录
|
||||
excel-search-rust --dir ./data --keyword 关键词 --include-subdirectories=false
|
||||
|
||||
# 文件类型过滤:all | excel | word | csv
|
||||
excel-search-rust --dir ./data --keyword 关键词 --file-type excel
|
||||
|
||||
# 并发 worker 数(或环境变量 EXCEL_SEARCH_WORKERS)
|
||||
excel-search-rust --dir ./data --keyword 关键词 --workers 16
|
||||
```
|
||||
|
||||
> 完整单词边界提示:汉字属于字母,因此开启完整单词后搜「手机」**不会**命中
|
||||
> 「苹果手机壳」这类内部子串;默认(关闭)为模糊匹配则会命中。
|
||||
|
||||
## 桌面版用法
|
||||
|
||||
```bash
|
||||
# 双击运行,直接弹出原生窗口
|
||||
excel-search-rust-desktop.exe
|
||||
```
|
||||
|
||||
窗口布局与 Go 桌面版一致:目录/关键字(含历史下拉)、四个搜索选项、结果表格
|
||||
(虚拟滚动、列宽可拖拽、右键复制/打开文件)。差异是架构性的、也是更好的:
|
||||
|
||||
- **关窗即退出**:原生窗口生命周期与进程绑定,无心跳检测;
|
||||
- **搜索不阻塞界面**:搜索在后台线程执行,进行中可继续拖动窗口/改表单;
|
||||
慢目录不再被 30s 服务端超时掐断(Go 版行为);
|
||||
- **中文字体**:运行时加载系统 SimHei/楷体/仿宋(epaint 不支持 .ttc 集合字体)。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `EXCEL_SEARCH_WORKERS` | `8` | 并发搜索线程数 |
|
||||
| `EXCEL_SEARCH_CACHE_DIR` | `.cache` | 搜索缓存目录 |
|
||||
|
||||
搜索历史写入 `.cache/search_history.json`(与 Go 桌面版同格式,可共用同一份历史)。
|
||||
|
||||
## 与 Go 版的差异
|
||||
|
||||
| 维度 | Go 版 | Rust 版 |
|
||||
|---|---|---|
|
||||
| 交付形态 | searcher CLI + WebUI + 桌面版(HTTP+Edge 窗口) | CLI + 桌面版(**原生 egui**,无 WebUI) |
|
||||
| Excel 格式 | xlsx/xlsm/xltx/xltm | 额外支持 xls/xlsb/ods |
|
||||
| 搜索取消/超时 | context 全链路(服务端 600s 超时) | CLI:Ctrl+C 直接终止;桌面:后台线程不阻塞 UI,无超时截断 |
|
||||
| 缓存 | JSON,版本 2 | 同 schema,条目可跨版本命中(已验证) |
|
||||
| 日志 | slog(text/json,含请求日志中间件) | CLI stderr `warn:` 行 |
|
||||
|
||||
## 设计文档(历史)
|
||||
|
||||
原始设计指南(体积优化 profile、calamine/rayon 选型)已随实现落地,
|
||||
关键决策保留在上文;`[profile.release]` 的 `opt-level="z"`、`lto`、`strip` 配置见 `Cargo.toml`。
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
rem ============================================================
|
||||
rem Excel Keyword Search Tool (Rust) - one-click build
|
||||
rem
|
||||
rem Usage:
|
||||
rem build.bat release build, default
|
||||
rem build.bat debug debug build
|
||||
rem build.bat test run cargo test
|
||||
rem
|
||||
rem Toolchain autodetect order:
|
||||
rem 1. CherryStudio mise managed cargo/rustup
|
||||
rem 2. system PATH cargo/rustup
|
||||
rem If a windows-gnu toolchain + winlibs mingw is available it is
|
||||
rem used automatically - works without Visual Studio.
|
||||
rem Otherwise the default msvc target is used - needs VS Build Tools.
|
||||
rem ASCII only - keep it that way for cmd GBK codepage safety.
|
||||
rem ============================================================
|
||||
|
||||
cd /d "%~dp0"
|
||||
set "ACTION=%~1"
|
||||
if "%ACTION%"=="" set "ACTION=release"
|
||||
|
||||
echo ============================================================
|
||||
echo excel-search-rust build script [action: %ACTION%]
|
||||
echo ============================================================
|
||||
|
||||
rem ---------- locate cargo / rustup ----------
|
||||
set "MISE_HOME=%APPDATA%\CherryStudio\Toolchain\mise\shims"
|
||||
set "RUSTUP_BIN="
|
||||
set "CARGO_BIN="
|
||||
|
||||
if exist "%MISE_HOME%\rustup.exe" set "RUSTUP_BIN=%MISE_HOME%\rustup.exe"
|
||||
if exist "%MISE_HOME%\cargo.exe" set "CARGO_BIN=%MISE_HOME%\cargo.exe"
|
||||
|
||||
if not defined CARGO_BIN (
|
||||
for /f "delims=" %%i in ('where cargo 2^>nul') do if not defined CARGO_BIN set "CARGO_BIN=%%i"
|
||||
)
|
||||
if not defined RUSTUP_BIN (
|
||||
for /f "delims=" %%i in ('where rustup 2^>nul') do if not defined RUSTUP_BIN set "RUSTUP_BIN=%%i"
|
||||
)
|
||||
|
||||
if not defined CARGO_BIN (
|
||||
echo [ERROR] cargo not found.
|
||||
echo Install Rust from https://rustup.rs , or via CherryStudio
|
||||
echo managed CLI with the mise recipe core:rust , then retry.
|
||||
goto :fail
|
||||
)
|
||||
echo [info] cargo : %CARGO_BIN%
|
||||
|
||||
rem ---------- prefer GNU host toolchain, no VS required ----------
|
||||
set "USE_GNU="
|
||||
set "GNU_TC=stable-x86_64-pc-windows-gnu"
|
||||
rem NOTE: call find by absolute path - GNU find from mingw/Git in PATH would hijack it
|
||||
if defined RUSTUP_BIN (
|
||||
"%RUSTUP_BIN%" toolchain list 2>nul | "%WINDIR%\System32\find.exe" "%GNU_TC%" >nul && set "USE_GNU=1"
|
||||
)
|
||||
|
||||
set "MINGW_BIN=%USERPROFILE%\tools\mingw64\bin"
|
||||
set "HAVE_MINGW="
|
||||
if exist "%MINGW_BIN%\gcc.exe" set "HAVE_MINGW=1"
|
||||
if not defined HAVE_MINGW (
|
||||
for /f "delims=" %%i in ('where gcc 2^>nul') do set "HAVE_MINGW=1"
|
||||
)
|
||||
|
||||
set "CMD_HEAD="
|
||||
if defined USE_GNU if defined HAVE_MINGW (
|
||||
if exist "%MINGW_BIN%\gcc.exe" set "PATH=%MINGW_BIN%;%PATH%"
|
||||
set "CMD_HEAD="%RUSTUP_BIN%" run %GNU_TC% cargo"
|
||||
echo [info] toolchain: %GNU_TC% gnu, no MSVC required
|
||||
)
|
||||
if not defined CMD_HEAD (
|
||||
set "CMD_HEAD="%CARGO_BIN%""
|
||||
echo [info] toolchain: default msvc
|
||||
echo [warn] no gnu toolchain + mingw found. The msvc target needs Visual
|
||||
echo Studio Build Tools / Windows SDK to link. To build without VS:
|
||||
echo 1. %RUSTUP_BIN% toolchain install %GNU_TC%
|
||||
echo 2. unzip winlibs mingw into %USERPROFILE%\tools\mingw64
|
||||
)
|
||||
|
||||
rem ---------- run ----------
|
||||
echo.
|
||||
if "%ACTION%"=="test" (
|
||||
%CMD_HEAD% test
|
||||
goto :post
|
||||
)
|
||||
if "%ACTION%"=="debug" (
|
||||
%CMD_HEAD% build
|
||||
goto :post
|
||||
)
|
||||
%CMD_HEAD% build --release
|
||||
|
||||
:post
|
||||
set "RC=%ERRORLEVEL%"
|
||||
echo.
|
||||
if not "%RC%"=="0" (
|
||||
echo [ERROR] build failed, exit code %RC%
|
||||
goto :fail
|
||||
)
|
||||
|
||||
if "%ACTION%"=="test" (
|
||||
echo [OK] all tests passed
|
||||
goto :done
|
||||
)
|
||||
|
||||
echo ============================================================
|
||||
echo [OK] build succeeded. outputs:
|
||||
if "%ACTION%"=="debug" (
|
||||
if exist "target\debug\excel-search-rust.exe" echo target\debug\excel-search-rust.exe
|
||||
if exist "target\debug\excel-search-rust-desktop.exe" echo target\debug\excel-search-rust-desktop.exe
|
||||
)
|
||||
if not "%ACTION%"=="debug" (
|
||||
for %%f in (excel-search-rust.exe excel-search-rust-desktop.exe) do (
|
||||
if exist "target\release\%%f" (
|
||||
for %%s in ("target\release\%%f") do echo target\release\%%f - %%~zs bytes
|
||||
)
|
||||
)
|
||||
)
|
||||
echo ============================================================
|
||||
|
||||
:done
|
||||
echo.
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:fail
|
||||
echo.
|
||||
pause
|
||||
exit /b 1
|
||||
@@ -0,0 +1,522 @@
|
||||
//! excel-search-rust-desktop 桌面版入口:**原生 egui 界面**(单 exe,无浏览器 / 无内嵌 HTML)。
|
||||
//!
|
||||
//! 与 Go 桌面版"HTTP 服务 + Edge 应用窗口"的前后端分离架构不同,Rust 版直接采用
|
||||
//! 原生 GUI(egui/eframe 0.36),因此获得:
|
||||
//! - 关窗即进程退出(无需心跳判活机制);
|
||||
//! - 搜索在后台线程执行,UI 全程可交互(无服务端超时截断);
|
||||
//! - 结果表格原生虚拟滚动(万级结果不卡顿,无分页条)。
|
||||
//!
|
||||
//! 共享行为与 Go 版一致:搜索历史读写 `.cache/search_history.json`(同格式共用),
|
||||
//! 文件级缓存与 Go 版同 schema(见 engine 文档)。
|
||||
//! 配置:`EXCEL_SEARCH_WORKERS`(并发数,默认 8)、`EXCEL_SEARCH_CACHE_DIR`(缓存目录)。
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
use egui_extras::{Column, TableBuilder};
|
||||
|
||||
use excel_search_rust::engine::{self, FileType, SearchResult};
|
||||
use excel_search_rust::history::HistoryStore;
|
||||
|
||||
/// 文件类型选项(UI 下拉),与 Go 版取值集合一致。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileTypeUi {
|
||||
All,
|
||||
Excel,
|
||||
Word,
|
||||
Csv,
|
||||
}
|
||||
|
||||
impl FileTypeUi {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
FileTypeUi::All => "所有文件",
|
||||
FileTypeUi::Excel => "Excel 文件",
|
||||
FileTypeUi::Word => "Word 文件",
|
||||
FileTypeUi::Csv => "CSV 文件",
|
||||
}
|
||||
}
|
||||
|
||||
fn to_engine(self) -> FileType {
|
||||
match self {
|
||||
FileTypeUi::All => FileType::All,
|
||||
FileTypeUi::Excel => FileType::Excel,
|
||||
FileTypeUi::Word => FileType::Word,
|
||||
FileTypeUi::Csv => FileType::Csv,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_or(key: &str, def: &str) -> String {
|
||||
std::env::var(key).unwrap_or_else(|_| def.to_string())
|
||||
}
|
||||
|
||||
struct App {
|
||||
// 表单状态
|
||||
dir: String,
|
||||
keyword: String,
|
||||
case_sensitive: bool,
|
||||
whole_word: bool, // 默认关闭(模糊匹配),与 Go 版一致
|
||||
include_subdirectories: bool,
|
||||
file_type: FileTypeUi,
|
||||
// 搜索状态:后台线程执行,logic 回调中轮询收集
|
||||
searching: bool,
|
||||
search_started: Option<Instant>,
|
||||
rx: Option<mpsc::Receiver<Vec<SearchResult>>>,
|
||||
results: Vec<SearchResult>,
|
||||
status: String,
|
||||
status_error: bool,
|
||||
workers: usize,
|
||||
history: HistoryStore,
|
||||
// 结果选中行(文件路径单元格点击高亮)
|
||||
selected_row: Option<usize>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
setup_cjk_fonts(&cc.egui_ctx);
|
||||
App {
|
||||
dir: ".".to_string(),
|
||||
keyword: String::new(),
|
||||
case_sensitive: false,
|
||||
whole_word: false,
|
||||
include_subdirectories: true,
|
||||
file_type: FileTypeUi::All,
|
||||
searching: false,
|
||||
search_started: None,
|
||||
rx: None,
|
||||
results: Vec::new(),
|
||||
status: "请输入路径与关键字开始搜索".to_string(),
|
||||
status_error: false,
|
||||
workers: env_or("EXCEL_SEARCH_WORKERS", "8").parse().unwrap_or(8),
|
||||
history: HistoryStore::load(),
|
||||
selected_row: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn start_search(&mut self) {
|
||||
if self.searching {
|
||||
return;
|
||||
}
|
||||
let keyword = self.keyword.trim().to_string();
|
||||
if keyword.is_empty() {
|
||||
self.status = "请输入搜索关键字".to_string();
|
||||
self.status_error = true;
|
||||
return;
|
||||
}
|
||||
let dir = {
|
||||
let d = self.dir.trim();
|
||||
if d.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
d.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// 记入并持久化历史(与 Go 桌面版同规则、同文件)
|
||||
self.history.push_dir(&dir);
|
||||
self.history.push_keyword(&keyword);
|
||||
self.history.save();
|
||||
|
||||
// 参数快照传入后台线程:搜索进行中修改表单不影响本次结果。
|
||||
let opts = engine::SearchOptions {
|
||||
case_sensitive: self.case_sensitive,
|
||||
whole_word: self.whole_word,
|
||||
include_subdirectories: self.include_subdirectories,
|
||||
file_type: self.file_type.to_engine(),
|
||||
};
|
||||
let workers = self.workers;
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let results = engine::search_directory(Path::new(&dir), &keyword, workers, &opts);
|
||||
let _ = tx.send(results); // 接收端已丢弃(如关窗)则静默结束
|
||||
});
|
||||
|
||||
self.rx = Some(rx);
|
||||
self.searching = true;
|
||||
self.search_started = Some(Instant::now());
|
||||
self.status = "正在检索(支持增量缓存加速)…".to_string();
|
||||
self.status_error = false;
|
||||
self.results.clear();
|
||||
self.selected_row = None;
|
||||
}
|
||||
|
||||
/// 收集后台搜索结果;进行中定期申请重绘以刷新耗时显示。
|
||||
fn poll_search(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.rx.as_ref() else { return };
|
||||
match rx.try_recv() {
|
||||
Ok(results) => {
|
||||
let dur = self
|
||||
.search_started
|
||||
.map(|t| t.elapsed().as_secs_f64())
|
||||
.unwrap_or_default();
|
||||
let count = results.len();
|
||||
self.results = results;
|
||||
self.searching = false;
|
||||
self.rx = None;
|
||||
self.status =
|
||||
format!("搜索完成!耗时 {dur:.2} 秒,共找到 {count} 个匹配项(本地缓存加速)");
|
||||
self.status_error = false;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {
|
||||
ctx.request_repaint_after(Duration::from_millis(200));
|
||||
}
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.searching = false;
|
||||
self.rx = None;
|
||||
self.status = "搜索线程异常结束".to_string();
|
||||
self.status_error = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
/// 非 UI 逻辑(每帧 ui() 之前调用,也可在窗口隐藏时运行):轮询后台搜索结果。
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.poll_search(ctx);
|
||||
}
|
||||
|
||||
fn ui(&mut self, root: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// ---------- 顶部表单 ----------
|
||||
// 0.36 面板统一为 egui::Panel(top/bottom/central)
|
||||
let trigger_search = egui::Panel::top("form")
|
||||
.show(root, |ui| {
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("📊 Excel 关键字智能搜索工具");
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.weak(format!("Rust v{}", env!("CARGO_PKG_VERSION")));
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.separator();
|
||||
|
||||
let mut trigger = false;
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("搜索目录:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.dir)
|
||||
.hint_text("不输入默认当前文件夹")
|
||||
.desired_width(ui.available_width() - 100.0),
|
||||
);
|
||||
egui::ComboBox::from_id_salt("dir_history")
|
||||
.selected_text("历史 ▾")
|
||||
.show_ui(ui, |ui| {
|
||||
for d in self.history.dirs.clone() {
|
||||
ui.selectable_value(&mut self.dir, d.clone(), d);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("关 键 字:");
|
||||
let resp = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.keyword)
|
||||
.hint_text("输入要查找的关键字...")
|
||||
.desired_width(ui.available_width() - 210.0),
|
||||
);
|
||||
egui::ComboBox::from_id_salt("key_history")
|
||||
.selected_text("历史 ▾")
|
||||
.show_ui(ui, |ui| {
|
||||
for k in self.history.keywords.clone() {
|
||||
ui.selectable_value(&mut self.keyword, k.clone(), k);
|
||||
}
|
||||
});
|
||||
if ui
|
||||
.add_enabled(!self.searching, egui::Button::new("🔍 开始搜索"))
|
||||
.clicked()
|
||||
{
|
||||
trigger = true;
|
||||
}
|
||||
// 关键字输入框持有焦点时回车快捷发起
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && resp.has_focus() {
|
||||
trigger = true;
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(&mut self.case_sensitive, "区分大小写");
|
||||
ui.checkbox(&mut self.whole_word, "完整单词匹配")
|
||||
.on_hover_text(
|
||||
"勾选后要求关键字前后均为单词边界;不勾选为模糊子串匹配(默认)",
|
||||
);
|
||||
ui.checkbox(&mut self.include_subdirectories, "包含子目录");
|
||||
ui.label("文件类型:");
|
||||
egui::ComboBox::from_id_salt("file_type")
|
||||
.selected_text(self.file_type.label())
|
||||
.show_ui(ui, |ui| {
|
||||
for ft in [
|
||||
FileTypeUi::All,
|
||||
FileTypeUi::Excel,
|
||||
FileTypeUi::Word,
|
||||
FileTypeUi::Csv,
|
||||
] {
|
||||
ui.selectable_value(&mut self.file_type, ft, ft.label());
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
trigger
|
||||
})
|
||||
.inner;
|
||||
if trigger_search {
|
||||
self.start_search();
|
||||
}
|
||||
|
||||
// ---------- 底部状态栏 ----------
|
||||
egui::Panel::bottom("status").show(root, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if self.searching {
|
||||
ui.spinner();
|
||||
let elapsed = self
|
||||
.search_started
|
||||
.map(|t| t.elapsed().as_secs_f32())
|
||||
.unwrap_or_default();
|
||||
ui.label(format!("{} {elapsed:.1}s", self.status));
|
||||
} else {
|
||||
let color = if self.status_error {
|
||||
egui::Color32::from_rgb(240, 100, 100)
|
||||
} else {
|
||||
egui::Color32::GRAY
|
||||
};
|
||||
ui.label(egui::RichText::new(&self.status).color(color));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{} 条匹配", self.results.len()))
|
||||
.strong()
|
||||
.color(egui::Color32::from_rgb(90, 170, 250)),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- 结果表格(原生虚拟滚动) ----------
|
||||
let keyword = self.keyword.trim().to_string();
|
||||
let status_text = self.status.clone();
|
||||
let results = &self.results;
|
||||
let selected = &mut self.selected_row;
|
||||
|
||||
egui::CentralPanel::default().show(root, |ui| {
|
||||
if results.is_empty() {
|
||||
ui.centered_and_justified(|ui| {
|
||||
ui.label(egui::RichText::new(&status_text).weak().size(15.0));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
TableBuilder::new(ui)
|
||||
.id_salt("results-table")
|
||||
.striped(true)
|
||||
.resizable(true)
|
||||
.column(Column::auto().at_least(46.0))
|
||||
.column(
|
||||
Column::initial(340.0)
|
||||
.at_least(120.0)
|
||||
.resizable(true)
|
||||
.clip(true),
|
||||
)
|
||||
.column(Column::auto().at_least(52.0))
|
||||
.column(Column::auto().at_least(120.0))
|
||||
.column(Column::remainder().at_least(200.0).resizable(true))
|
||||
.header(26.0, |mut row| {
|
||||
for name in ["序号", "文件路径", "类型", "Sheet / 位置", "匹配内容"]
|
||||
{
|
||||
row.col(|ui| {
|
||||
ui.strong(name);
|
||||
});
|
||||
}
|
||||
})
|
||||
.body(|body| {
|
||||
body.rows(24.0, results.len(), |mut row| {
|
||||
let i = row.index();
|
||||
let r = &results[i];
|
||||
|
||||
row.col(|ui| {
|
||||
ui.monospace(format!("{}", i + 1));
|
||||
});
|
||||
row.col(|ui| {
|
||||
let text = egui::RichText::new(&r.file_path)
|
||||
.color(egui::Color32::from_rgb(100, 160, 240));
|
||||
let resp = ui
|
||||
.add(egui::Label::new(text).truncate())
|
||||
.on_hover_text(&r.file_path);
|
||||
if resp.clicked() {
|
||||
*selected = Some(i);
|
||||
}
|
||||
resp.context_menu(|ui| context_menu_for(ui, r));
|
||||
});
|
||||
row.col(|ui| {
|
||||
ui.label(egui::RichText::new(&r.file_type).small());
|
||||
});
|
||||
row.col(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(&r.sheet_name)
|
||||
.color(egui::Color32::from_rgb(100, 160, 240)),
|
||||
);
|
||||
ui.weak(format!("({})", r.location));
|
||||
});
|
||||
});
|
||||
row.col(|ui| {
|
||||
// 命中内容使用浅黄高亮底色,确保在深色/浅色主题下文字均可读
|
||||
let bg = if *selected == Some(i) {
|
||||
egui::Color32::from_rgb(255, 235, 120)
|
||||
} else {
|
||||
egui::Color32::from_rgb(255, 250, 200)
|
||||
};
|
||||
let resp = ui
|
||||
.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(&r.cell_content)
|
||||
.background_color(bg)
|
||||
.color(egui::Color32::BLACK),
|
||||
)
|
||||
.truncate(),
|
||||
)
|
||||
.on_hover_text(format!("关键字: {keyword}(双击打开文件)"));
|
||||
if resp.double_clicked() {
|
||||
open_in_os(&r.file_path, false);
|
||||
}
|
||||
resp.context_menu(|ui| context_menu_for(ui, r));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 结果行右键菜单:复制 + 打开(对应 Go 版前端 contextMenu)。
|
||||
fn context_menu_for(ui: &mut egui::Ui, r: &SearchResult) {
|
||||
if ui.button("📄 复制文件名").clicked() {
|
||||
ui.ctx().copy_text(file_name_of(&r.file_path).to_string());
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("📋 复制文件路径").clicked() {
|
||||
ui.ctx().copy_text(r.file_path.clone());
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("💬 复制匹配内容").clicked() {
|
||||
ui.ctx().copy_text(r.cell_content.clone());
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("📂 打开文件所在文件夹").clicked() {
|
||||
open_in_os(&r.file_path, true);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("🚀 打开文件").clicked() {
|
||||
open_in_os(&r.file_path, false);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name_of(path: &str) -> &str {
|
||||
let trimmed = path.trim_end_matches(['\\', '/']);
|
||||
trimmed
|
||||
.rsplit_once(['\\', '/'])
|
||||
.map(|(_, name)| name)
|
||||
.unwrap_or(trimmed)
|
||||
}
|
||||
|
||||
/// 用系统默认程序打开文件 / 所在目录(对应 Go openExplorer)。
|
||||
fn open_in_os(path: &str, as_folder: bool) {
|
||||
let p = Path::new(path);
|
||||
let target = if as_folder {
|
||||
p.parent()
|
||||
.map(|d| d.to_path_buf())
|
||||
.unwrap_or_else(|| p.to_path_buf())
|
||||
} else {
|
||||
p.to_path_buf()
|
||||
};
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = if as_folder {
|
||||
std::process::Command::new("explorer").arg(target).spawn()
|
||||
} else {
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/c", "start", "", &target.display().to_string()])
|
||||
.spawn()
|
||||
};
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = std::process::Command::new("open").arg(target).spawn();
|
||||
}
|
||||
#[cfg(not(any(windows, target_os = "macos")))]
|
||||
{
|
||||
let _ = std::process::Command::new("xdg-open").arg(target).spawn();
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册中文字体:优先 Windows 自带的单文件 .ttf(epaint 不支持 .ttc 集合,
|
||||
/// msyh.ttc 仅作尽力尝试);全部失败时保留内置字体(不崩溃,仅中文变方块)。
|
||||
fn setup_cjk_fonts(ctx: &egui::Context) {
|
||||
#[cfg(windows)]
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"C:\\Windows\\Fonts\\simhei.ttf",
|
||||
"C:\\Windows\\Fonts\\simkai.ttf",
|
||||
"C:\\Windows\\Fonts\\simfang.ttf",
|
||||
"C:\\Windows\\Fonts\\msyh.ttc",
|
||||
];
|
||||
#[cfg(not(windows))]
|
||||
const CANDIDATES: &[&str] = &[
|
||||
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
||||
];
|
||||
|
||||
for path in CANDIDATES {
|
||||
let Ok(bytes) = std::fs::read(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
fonts.font_data.insert(
|
||||
"cjk".to_owned(),
|
||||
std::sync::Arc::new(egui::FontData::from_owned(bytes)),
|
||||
);
|
||||
for family in [egui::FontFamily::Proportional, egui::FontFamily::Monospace] {
|
||||
if let Some(list) = fonts.families.get_mut(&family) {
|
||||
list.push("cjk".to_owned());
|
||||
}
|
||||
}
|
||||
ctx.set_fonts(fonts);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_inner_size([1040.0, 680.0])
|
||||
.with_min_inner_size([760.0, 460.0])
|
||||
.with_title("Excel 关键字智能搜索工具"),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"excel-search-rust-desktop",
|
||||
options,
|
||||
Box::new(|cc| Ok(Box::new(App::new(cc)))),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_type_ui_mapping() {
|
||||
assert_eq!(FileTypeUi::All.to_engine(), FileType::All);
|
||||
assert_eq!(FileTypeUi::Csv.to_engine(), FileType::Csv);
|
||||
assert_eq!(FileTypeUi::Word.label(), "Word 文件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_name_extraction_handles_both_separators() {
|
||||
assert_eq!(file_name_of("C:\\a\\b\\data.xlsx"), "data.xlsx");
|
||||
assert_eq!(file_name_of("D:/dir/notes.txt"), "notes.txt");
|
||||
assert_eq!(file_name_of("plain.csv"), "plain.csv");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 文件级结果缓存:加速同一目录下关键字的重复搜索。
|
||||
//!
|
||||
//! 缓存文件按搜索根目录隔离:`<cache_dir>/cache_kw_<sha256(绝对路径)[..16]>.json`,
|
||||
//! 其中 `cache_dir` 取环境变量 `EXCEL_SEARCH_CACHE_DIR`(为空时默认运行目录下 `.cache/`)。
|
||||
//!
|
||||
//! 条目命中条件为“文件路径 + 关键字 + ModTime(ns) + Size”全部一致,
|
||||
//! 文件一旦改动即自动失效。JSON 结构与 Go 版(`internal/search/scanner.go`)
|
||||
//! 完全兼容:两版工具在同一目录下共存读写同一份缓存文件而互不损坏;
|
||||
//! 文件条目内路径均已规范化(Rust 侧 absolute(),Go 侧 filepath.Join+Clean),
|
||||
//! 传入相同的目录取值时缓存条目可跨版本互相命中。
|
||||
//! 缓存只是加速项:任何读写异常都静默降级为“无缓存”,不影响结果正确性。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::SearchResult;
|
||||
|
||||
/// 缓存结构版本号。匹配语义变化时递增,旧版本缓存整体失效
|
||||
/// (与 Go 版 cacheSchemaVersion 保持同值,确保互换时互不污染)。
|
||||
pub const SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// 单个文件对某个关键字的历史搜索结果。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct FileCacheEntry {
|
||||
/// 文件修改时间(UNIX 纳秒)
|
||||
pub mod_time: i64,
|
||||
/// 文件大小(字节)
|
||||
pub size: i64,
|
||||
/// 历史命中结果(可能为空——空结果同样值得缓存,避免重复解析)
|
||||
pub results: Vec<SearchResult>,
|
||||
}
|
||||
|
||||
/// 缓存文件信封:带结构版本号,版本不一致时整文件作废。
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct CacheFile {
|
||||
version: u32,
|
||||
cache: HashMap<String, FileCacheEntry>,
|
||||
}
|
||||
|
||||
/// 根据搜索根目录生成稳定的缓存文件路径(与 Go 版同名同规则)。
|
||||
pub fn cache_file_path(root: &Path, cache_dir: Option<&Path>) -> PathBuf {
|
||||
// std::path::absolute 只做“拼接 + 规范化”,不解析符号链接、不加 \\?\ 前缀,
|
||||
// 与 Go 的 filepath.Abs 结果一致,从而保证 sha256 输入相同。
|
||||
let abs = std::path::absolute(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
let digest = Sha256::digest(abs.to_string_lossy().as_bytes());
|
||||
let hex: String = digest[..16].iter().map(|b| format!("{b:02x}")).collect();
|
||||
|
||||
let dir = match cache_dir {
|
||||
Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
|
||||
_ => PathBuf::from(".cache"),
|
||||
};
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
dir.join(format!("cache_kw_{hex}.json"))
|
||||
}
|
||||
|
||||
/// 读取缓存文件;文件缺失、JSON 损坏或版本不匹配时返回空表。
|
||||
pub fn load(path: &Path) -> HashMap<String, FileCacheEntry> {
|
||||
match fs::read_to_string(path) {
|
||||
Ok(data) => match serde_json::from_str::<CacheFile>(&data) {
|
||||
Ok(cf) if cf.version == SCHEMA_VERSION => cf.cache,
|
||||
_ => HashMap::new(),
|
||||
},
|
||||
Err(_) => HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将缓存表整体序列化写回磁盘;写入失败静默忽略。
|
||||
pub fn save(path: &Path, entries: &HashMap<String, FileCacheEntry>) {
|
||||
let envelope = CacheFile {
|
||||
version: SCHEMA_VERSION,
|
||||
cache: entries.clone(),
|
||||
};
|
||||
if let Ok(data) = serde_json::to_vec(&envelope) {
|
||||
let _ = fs::write(path, data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! 关键字匹配器:封装一次搜索的全部匹配语义,
|
||||
//! 供 Excel / 文本 / Word 三类解析器共用,保证行为一致。
|
||||
//!
|
||||
//! 语义与 Go 版 `internal/search/matcher.go` 对齐:
|
||||
//! - 关键字与文本均做 Unicode NFC 规范化;
|
||||
//! - 不区分大小写模式下做 Unicode 小写折叠(Go 用 full case folding,
|
||||
//! 两者仅个别特殊字符有差异,常规中英文结果一致);
|
||||
//! - 完整单词边界规则:单词字符 = 字母 / 数字 / Unicode 组合标记 / 下划线。
|
||||
//! 该规则对中文亦成立——汉字是字母,故中文子串匹配需保持默认(关闭完整单词)。
|
||||
|
||||
use unicode_categories::UnicodeCategories;
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
use super::SearchOptions;
|
||||
|
||||
/// 构造期完成关键字的规范化与折叠,避免在热路径重复处理。
|
||||
pub struct Matcher {
|
||||
/// 已 NFC 规范化(且不区分大小写模式下已小写折叠)的关键字
|
||||
keyword: String,
|
||||
/// 大小写不敏感
|
||||
ci: bool,
|
||||
/// 完整单词(要求前后均为单词边界)
|
||||
whole: bool,
|
||||
}
|
||||
|
||||
impl Matcher {
|
||||
pub fn new(keyword: &str, opts: &SearchOptions) -> Self {
|
||||
Matcher {
|
||||
keyword: Self::prepare(keyword, opts.case_sensitive),
|
||||
ci: !opts.case_sensitive,
|
||||
whole: opts.whole_word,
|
||||
}
|
||||
}
|
||||
|
||||
/// 结果中回填的 matched_key:规范化/折叠后的关键字形态。
|
||||
pub fn matched_key(&self) -> &str {
|
||||
&self.keyword
|
||||
}
|
||||
|
||||
fn prepare(s: &str, case_sensitive: bool) -> String {
|
||||
let normalized: String = s.nfc().collect();
|
||||
if case_sensitive {
|
||||
normalized
|
||||
} else {
|
||||
normalized.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告 text 是否命中关键字。
|
||||
pub fn is_match(&self, text: &str) -> bool {
|
||||
if self.keyword.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = Self::prepare(text, !self.ci);
|
||||
if !self.whole {
|
||||
return t.contains(self.keyword.as_str());
|
||||
}
|
||||
self.whole_word_match(&t)
|
||||
}
|
||||
|
||||
/// 要求命中位置的前后字符均不是单词字符(行首/行尾视为边界)。
|
||||
/// 逐字符推进扫描重叠候选:UTF-8 自同步保证候选起点必落在字符边界上,
|
||||
/// 因此跳过匹配词首字符即可与 Go 版逐字节扫描得到相同的命中集合。
|
||||
fn whole_word_match(&self, t: &str) -> bool {
|
||||
// 关键字匹配词的首字符宽度(>=1),用于安全推进扫描位置
|
||||
let first_char_len = self.keyword.chars().next().map(char::len_utf8).unwrap_or(1);
|
||||
let mut start = 0usize;
|
||||
while let Some(pos) = t[start..].find(self.keyword.as_str()) {
|
||||
let at = start + pos;
|
||||
let before_ok = t[..at]
|
||||
.chars()
|
||||
.next_back()
|
||||
.map_or(true, |c| !is_word_char(c));
|
||||
let after_ok = t[at + self.keyword.len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.map_or(true, |c| !is_word_char(c));
|
||||
if before_ok && after_ok {
|
||||
return true;
|
||||
}
|
||||
start = at + first_char_len;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断字符是否构成“单词”的一部分(与 Go 版 isWordRune 一致:L / N / M / 下划线)。
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_letter() || c.is_number() || c.is_mark() || c == '_'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::{FileType, SearchOptions};
|
||||
|
||||
fn opts(case_sensitive: bool, whole_word: bool) -> SearchOptions {
|
||||
SearchOptions {
|
||||
case_sensitive,
|
||||
whole_word,
|
||||
include_subdirectories: true,
|
||||
file_type: FileType::All,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_fuzzy_case_insensitive() {
|
||||
// 默认选项(不区分大小写 + 模糊):"Alph" 命中 "Alpha",大小写互认。
|
||||
let m = Matcher::new("Alph", &SearchOptions::default());
|
||||
assert!(m.is_match("alpha gamma"));
|
||||
let m = Matcher::new("alpha", &SearchOptions::default());
|
||||
assert!(m.is_match("Alpha Beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_sensitive_mode() {
|
||||
let m = Matcher::new("Alpha", &opts(true, false));
|
||||
assert!(!m.is_match("alpha"));
|
||||
assert!(m.is_match("Alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_word_boundaries() {
|
||||
let m = Matcher::new("cat", &opts(false, true));
|
||||
for (text, want) in [
|
||||
("a cat.", true),
|
||||
("cat", true),
|
||||
("cat jumped", true),
|
||||
("concatenate", false),
|
||||
("scatter", false),
|
||||
("cat2", false),
|
||||
("_cat", false),
|
||||
("cat_", false),
|
||||
("catalog", false),
|
||||
] {
|
||||
assert_eq!(m.is_match(text), want, "whole-word 'cat' vs {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_whole_word() {
|
||||
// 汉字属于字母:完整单词模式下 "手机" 不命中内部子串,空格/标点后命中。
|
||||
let whole = Matcher::new("手机", &opts(false, true));
|
||||
assert!(!whole.is_match("苹果手机壳"));
|
||||
assert!(!whole.is_match("苹果手机"));
|
||||
assert!(whole.is_match("苹果 手机"));
|
||||
// 默认(模糊)模式下子串命中。
|
||||
let fuzzy = Matcher::new("手机", &SearchOptions::default());
|
||||
assert!(fuzzy.is_match("苹果手机壳"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn punctuation_keyword() {
|
||||
// '+' 非单词字符:"C++" 前后皆标点时视为边界;相邻字母则阻断。
|
||||
let m = Matcher::new("C++", &opts(false, true));
|
||||
assert!(m.is_match("learn C++ today"));
|
||||
assert!(m.is_match("C++++"));
|
||||
assert!(!m.is_match("abcC++"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_case_fold() {
|
||||
let m = Matcher::new("ÅNGSTRÖM", &SearchOptions::default());
|
||||
assert!(m.is_match("ångström"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nfc_normalization() {
|
||||
// 关键字用预组合 "é"(U+00E9),文本用分解形式 "e + U+0301",NFC 下应等价命中。
|
||||
let m = Matcher::new("café", &SearchOptions::default());
|
||||
assert!(m.is_match("cafe\u{301}"));
|
||||
// 反向:关键字为分解形式、文本为预组合同样命中。
|
||||
let m = Matcher::new("cafe\u{301}", &SearchOptions::default());
|
||||
assert!(m.is_match("café"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_keyword_never_matches() {
|
||||
let m = Matcher::new("", &SearchOptions::default());
|
||||
assert!(!m.is_match("anything"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_candidates_do_not_panic() {
|
||||
// 多字节关键字 + 重叠候选场景:扫描推进必须落在字符边界上。
|
||||
let m = Matcher::new("ana", &opts(false, true));
|
||||
assert!(!m.is_match("bananana")); // 前后均为字母 → 不命中
|
||||
let m = Matcher::new("ana", &opts(false, false));
|
||||
assert!(m.is_match("bananana"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! 搜索引擎核心:并发扫描编排、文件级缓存与结果聚合。
|
||||
//!
|
||||
//! 入口为 [`search_directory`](或其可注入缓存目录的 [`search_directory_with_cache`]):
|
||||
//! 1. [`walker`] 按文件类型选项收集待扫描文件列表;
|
||||
//! 2. rayon 线程池并行处理每个文件:先查文件级缓存([`cache`]),
|
||||
//! 未命中则按扩展名分发到 [`parser`] 中的对应解析器;
|
||||
//! 3. 匹配语义统一由 [`matcher`] 提供(NFC 规范化 + 大小写折叠 + 可选完整单词边界);
|
||||
//! 4. 全部文件处理完后,仅当缓存有新增/变更条目时整体写盘。
|
||||
//!
|
||||
//! 单文件解析失败只记告警并跳过,不影响其余文件(与 Go 版一致)。
|
||||
|
||||
mod cache;
|
||||
mod matcher;
|
||||
mod parser;
|
||||
mod walker;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use rayon::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use cache::FileCacheEntry;
|
||||
use matcher::Matcher;
|
||||
|
||||
// 类型与选项定义放在引擎根模块,供 CLI 与解析器共用。
|
||||
|
||||
/// 文件类型过滤范围,与 UI 下拉选项一一对应。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FileType {
|
||||
/// 全部支持格式(含 .txt)
|
||||
All,
|
||||
/// 仅 Excel 工作簿
|
||||
Excel,
|
||||
/// 仅 Word 文档(OOXML)
|
||||
Word,
|
||||
/// 仅 CSV
|
||||
Csv,
|
||||
}
|
||||
|
||||
impl FileType {
|
||||
/// 展示/日志用的字符串形式(与 Go 版取值一致)。
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
FileType::All => "all",
|
||||
FileType::Excel => "excel",
|
||||
FileType::Word => "word",
|
||||
FileType::Csv => "csv",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次搜索的全部匹配与遍历选项。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SearchOptions {
|
||||
pub case_sensitive: bool,
|
||||
pub whole_word: bool,
|
||||
pub include_subdirectories: bool,
|
||||
pub file_type: FileType,
|
||||
}
|
||||
|
||||
impl Default for SearchOptions {
|
||||
/// 产品默认值:不区分大小写、不启用完整单词(默认模糊子串匹配)、
|
||||
/// 递归子目录、扫描全部支持的文件类型。与 Go 版 DefaultSearchOptions 对齐。
|
||||
fn default() -> Self {
|
||||
SearchOptions {
|
||||
case_sensitive: false,
|
||||
whole_word: false,
|
||||
include_subdirectories: true,
|
||||
file_type: FileType::All,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Excel 类扩展名。Rust 版在 Go 版基础上额外支持 calamine 的
|
||||
/// .xls / .xlsb / .ods(README 设计目标中的“全格式 Excel 解析”)。
|
||||
const EXCEL_EXTS: &[&str] = &["xlsx", "xlsm", "xltx", "xltm", "xls", "xlsb", "ods"];
|
||||
const WORD_EXTS: &[&str] = &["docx", "docm"];
|
||||
const TEXT_EXTS: &[&str] = &["csv", "txt"];
|
||||
const ALL_EXTS: &[&str] = &[
|
||||
"xlsx", "xlsm", "xltx", "xltm", "xls", "xlsb", "ods", "docx", "docm", "csv", "txt",
|
||||
];
|
||||
|
||||
/// 解析器分发目标类别。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FileKind {
|
||||
Excel,
|
||||
Word,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl SearchOptions {
|
||||
/// 判断路径扩展名(大小写不敏感)是否属于当前文件类型范围。
|
||||
/// 注意:Csv 过滤仅含 .csv,不含 .txt(txt 只在 All 中,与 Go 版一致)。
|
||||
fn ext_allowed(&self, path: &Path) -> bool {
|
||||
let ext = file_ext(path);
|
||||
if ext.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match self.file_type {
|
||||
FileType::Excel => EXCEL_EXTS.contains(&ext.as_str()),
|
||||
FileType::Word => WORD_EXTS.contains(&ext.as_str()),
|
||||
FileType::Csv => ext == "csv",
|
||||
FileType::All => ALL_EXTS.contains(&ext.as_str()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回路径对应的解析器分发类别。调用前提:扩展名已通过 [`Self::ext_allowed`]。
|
||||
fn kind_of(&self, path: &Path) -> FileKind {
|
||||
let ext = file_ext(path);
|
||||
if WORD_EXTS.contains(&ext.as_str()) {
|
||||
FileKind::Word
|
||||
} else if TEXT_EXTS.contains(&ext.as_str()) {
|
||||
FileKind::Text
|
||||
} else {
|
||||
FileKind::Excel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 取小写无点扩展名;无扩展名返回空串。
|
||||
fn file_ext(path: &Path) -> String {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 一次关键字命中:哪个文件的哪个位置、因哪个关键字、命中了什么原文。
|
||||
/// JSON 字段名与 Go 版 `internal/model.SearchResult` 保持字节级兼容,
|
||||
/// 因此文件级缓存文件可与 Go 版互换读写。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct SearchResult {
|
||||
/// 命中文件的完整路径
|
||||
pub file_path: String,
|
||||
/// 小写无点扩展名:xlsx / docx / csv / txt 等
|
||||
pub file_type: String,
|
||||
/// Excel 工作表名;Word 固定 "Document";文本/CSV 为 "-"
|
||||
pub sheet_name: String,
|
||||
/// 命中位置:Excel 如 "A1",文本/CSV 如 "Line 3",Word 如 "Paragraph 2"
|
||||
pub location: String,
|
||||
/// 本次搜索的关键字(matcher 规范化并大小写折叠后的形态)
|
||||
pub matched_key: String,
|
||||
/// 命中处原文:单元格值 / 整行文本(去首尾空白)/ 段落文本
|
||||
pub cell_content: String,
|
||||
}
|
||||
|
||||
/// 并发扫描 root 下符合 opts.file_type 的文件,返回全部命中结果。
|
||||
/// workers 为并发解析线程数(0 视为 1)。缓存目录读环境变量
|
||||
/// `EXCEL_SEARCH_CACHE_DIR`(为空时回落到运行目录下 `.cache/`)。
|
||||
pub fn search_directory(
|
||||
root: &Path,
|
||||
keyword: &str,
|
||||
workers: usize,
|
||||
opts: &SearchOptions,
|
||||
) -> Vec<SearchResult> {
|
||||
let cache_dir = std::env::var_os("EXCEL_SEARCH_CACHE_DIR").map(PathBuf::from);
|
||||
search_directory_with_cache(root, keyword, workers, opts, cache_dir.as_deref())
|
||||
}
|
||||
|
||||
/// search_directory 的可测试变体:显式指定缓存目录(None 用默认 `.cache/`)。
|
||||
/// 单元测试并行运行时避免共享环境变量互相污染。
|
||||
pub fn search_directory_with_cache(
|
||||
root: &Path,
|
||||
keyword: &str,
|
||||
workers: usize,
|
||||
opts: &SearchOptions,
|
||||
cache_dir: Option<&Path>,
|
||||
) -> Vec<SearchResult> {
|
||||
let m = Matcher::new(keyword, opts);
|
||||
let files = walker::collect_files(root, opts);
|
||||
|
||||
let cache_path = cache::cache_file_path(root, cache_dir);
|
||||
let base_cache = cache::load(&cache_path);
|
||||
// 缓存键中的选项指纹:大小写/完整单词语义或缓存结构版本变化时必须绕开旧条目。
|
||||
// 格式与 Go 版一致("bool|bool|version"),保证缓存互换。
|
||||
let opt_fp = format!(
|
||||
"{}|{}|{}",
|
||||
opts.case_sensitive,
|
||||
opts.whole_word,
|
||||
cache::SCHEMA_VERSION
|
||||
);
|
||||
|
||||
// rayon 并行处理文件列表。缓存以只读方式共享(每个任务只读命中判断),
|
||||
// 新增条目随任务结果返回,收集完成后统一合并写盘——避免加锁竞争。
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(workers.max(1))
|
||||
.build()
|
||||
.expect("failed to build rayon thread pool");
|
||||
let outcomes: Vec<Outcome> = pool.install(|| {
|
||||
files
|
||||
.par_iter()
|
||||
.map(|path| process_file(path, keyword, &m, &opt_fp, opts, &base_cache))
|
||||
.collect()
|
||||
});
|
||||
|
||||
let mut merged = base_cache;
|
||||
let mut dirty = false;
|
||||
let mut all: Vec<SearchResult> = Vec::new();
|
||||
for o in outcomes {
|
||||
if let Some(entry) = o.new_entry {
|
||||
merged.insert(o.key, entry);
|
||||
dirty = true;
|
||||
}
|
||||
all.extend(o.results);
|
||||
}
|
||||
// 仅当本次新增/更新过缓存条目时才写盘,避免无谓文件 IO。
|
||||
if dirty {
|
||||
cache::save(&cache_path, &merged);
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
/// 单个文件的处理产物:结果 + 可写回缓存的条目(解析失败则无条目)。
|
||||
struct Outcome {
|
||||
key: String,
|
||||
results: Vec<SearchResult>,
|
||||
new_entry: Option<FileCacheEntry>,
|
||||
}
|
||||
|
||||
/// 处理单个文件:缓存命中直接复用,否则解析并按需生成新缓存条目。
|
||||
fn process_file(
|
||||
path: &Path,
|
||||
keyword: &str,
|
||||
m: &Matcher,
|
||||
opt_fp: &str,
|
||||
opts: &SearchOptions,
|
||||
base_cache: &HashMap<String, FileCacheEntry>,
|
||||
) -> Outcome {
|
||||
let key = format!("{}|{}|{}", path.display(), keyword, opt_fp);
|
||||
|
||||
// 读取文件指纹(ModTime + Size),与缓存条目比对:一致则复用,改动即失效。
|
||||
let md = match fs::metadata(path) {
|
||||
Ok(md) => md,
|
||||
Err(_) => {
|
||||
return Outcome {
|
||||
key,
|
||||
results: Vec::new(),
|
||||
new_entry: None,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mod_time = md
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_nanos() as i64)
|
||||
.unwrap_or(0);
|
||||
let size = md.len() as i64;
|
||||
|
||||
if let Some(hit) = base_cache.get(&key) {
|
||||
if hit.mod_time == mod_time && hit.size == size {
|
||||
return Outcome {
|
||||
key,
|
||||
results: hit.results.clone(),
|
||||
new_entry: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let parsed = match opts.kind_of(path) {
|
||||
FileKind::Excel => parser::parse_excel(path, m),
|
||||
FileKind::Word => parser::parse_docx(path, m),
|
||||
FileKind::Text => parser::parse_text(path, m),
|
||||
};
|
||||
match parsed {
|
||||
// 解析成功(含 0 命中)才写缓存;失败结果不入缓存,避免污染。
|
||||
Ok(results) => {
|
||||
let entry = FileCacheEntry {
|
||||
mod_time,
|
||||
size,
|
||||
results: results.clone(),
|
||||
};
|
||||
Outcome {
|
||||
key,
|
||||
results,
|
||||
new_entry: Some(entry),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("warn: parse error path={} err={}", path.display(), err);
|
||||
Outcome {
|
||||
key,
|
||||
results: Vec::new(),
|
||||
new_entry: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! 三类文件解析器:Excel(calamine)、Word(zip + quick-xml)、文本/CSV(按行)。
|
||||
//!
|
||||
//! 统一约定:
|
||||
//! - 命中判定全部交给 [`Matcher`],解析器只负责“取出候选文本 + 定位坐标”;
|
||||
//! - 位置格式与 Go 版一致:Excel "A1" / 文本 "Line N" / Word "Paragraph N";
|
||||
//! - 单 sheet / 单段落读取失败不中断其余部分(与 Go 版一致)。
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use calamine::{open_workbook_auto, DataType, Reader};
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::Reader as XmlReader;
|
||||
|
||||
use super::matcher::Matcher;
|
||||
use super::{file_ext, SearchResult};
|
||||
use crate::error::{Result, SearchError};
|
||||
|
||||
/// 逐 sheet 遍历工作簿,对每个非空单元格做匹配。
|
||||
/// 命中位置记为“列字母+行号”(如 A12);单个 sheet 解析失败跳过不中断。
|
||||
pub fn parse_excel(path: &Path, m: &Matcher) -> Result<Vec<SearchResult>> {
|
||||
let mut wb = open_workbook_auto(path).map_err(|e| SearchError::Excel(e.to_string()))?;
|
||||
let ext = file_ext(path);
|
||||
let mut results = Vec::new();
|
||||
|
||||
for name in wb.sheet_names().clone() {
|
||||
// worksheet_range 返回 Option<Result<...>>:缺失/损坏的 sheet 跳过不中断
|
||||
let range = match wb.worksheet_range(&name) {
|
||||
Some(Ok(r)) => r,
|
||||
_ => continue,
|
||||
};
|
||||
for (row_idx, row) in range.rows().enumerate() {
|
||||
for (col_idx, cell) in row.iter().enumerate() {
|
||||
let text = cell_text(cell);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if m.is_match(&text) {
|
||||
results.push(SearchResult {
|
||||
file_path: path.display().to_string(),
|
||||
file_type: ext.clone(),
|
||||
sheet_name: name.clone(),
|
||||
location: format!("{}{}", column_name(col_idx), row_idx + 1),
|
||||
matched_key: m.matched_key().to_string(),
|
||||
cell_content: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 单元格值的文本形态:字符串原样,数值/布尔走 calamine Display,
|
||||
/// 与 Go 版 excelize Rows() 的字符串化行为近似(日期数值形态不解析)。
|
||||
fn cell_text(v: &DataType) -> String {
|
||||
match v {
|
||||
DataType::Empty => String::new(),
|
||||
DataType::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 0 起始列号转 Excel 列字母(A, B, ..., Z, AA, ...)。
|
||||
fn column_name(idx: usize) -> String {
|
||||
let mut n = idx + 1;
|
||||
let mut s = String::new();
|
||||
while n > 0 {
|
||||
n -= 1;
|
||||
s.insert(0, (b'A' + (n % 26) as u8) as char);
|
||||
n /= 26;
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// 按行读取 .txt/.csv,整行命中即记录,位置为 "Line N"。
|
||||
/// CSV 不做字段解析,整行视为纯文本(与 Go 版一致)。
|
||||
pub fn parse_text(path: &Path, m: &Matcher) -> Result<Vec<SearchResult>> {
|
||||
let file = File::open(path)?;
|
||||
let reader = BufReader::new(file);
|
||||
let ext = file_ext(path);
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
// 读到中途 IO 错误即停止(与 Go 版 break 行为一致)
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => break,
|
||||
};
|
||||
if m.is_match(&line) {
|
||||
results.push(SearchResult {
|
||||
file_path: path.display().to_string(),
|
||||
file_type: ext.clone(),
|
||||
sheet_name: "-".to_string(),
|
||||
location: format!("Line {}", i + 1),
|
||||
matched_key: m.matched_key().to_string(),
|
||||
cell_content: line.trim().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 解析 .docx/.docm 的 word/document.xml,逐段落提取文本并匹配。
|
||||
/// 表格中的段落同样位于 document.xml,故一并覆盖;位置为 "Paragraph N"。
|
||||
pub fn parse_docx(path: &Path, m: &Matcher) -> Result<Vec<SearchResult>> {
|
||||
let file = File::open(path)?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| SearchError::Zip(e.to_string()))?;
|
||||
let mut doc = zip.by_name("word/document.xml").map_err(|_| {
|
||||
SearchError::Zip(format!("word document.xml not found in {}", path.display()))
|
||||
})?;
|
||||
// document.xml 整体读入内存后切片解析:&[u8] 上的 read_event 为 quick-xml 的零拷贝快速路径
|
||||
let mut xml_bytes = Vec::new();
|
||||
doc.read_to_end(&mut xml_bytes)?;
|
||||
let paras = parse_paragraphs(&xml_bytes)?;
|
||||
|
||||
let ext = file_ext(path);
|
||||
let mut results = Vec::new();
|
||||
for (i, p) in paras.iter().enumerate() {
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if m.is_match(p) {
|
||||
results.push(SearchResult {
|
||||
file_path: path.display().to_string(),
|
||||
file_type: ext.clone(),
|
||||
sheet_name: "Document".to_string(),
|
||||
location: format!("Paragraph {}", i + 1),
|
||||
matched_key: m.matched_key().to_string(),
|
||||
cell_content: p.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// 从 document.xml 按文档顺序提取全部段落文本:
|
||||
/// 仅收集 <w:p> 内 <w:t> 的字符数据,段落结束(</w:p>)时封存并去除首尾空白。
|
||||
fn parse_paragraphs(xml_data: &[u8]) -> Result<Vec<String>> {
|
||||
// quick-xml 0.31 的 &[u8] 读取器为零拷贝 API:read_event() 无参
|
||||
let mut xml = XmlReader::from_reader(xml_data);
|
||||
let mut paras: Vec<String> = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_para = false;
|
||||
let mut in_text = false;
|
||||
|
||||
loop {
|
||||
match xml
|
||||
.read_event()
|
||||
.map_err(|e| SearchError::Xml(e.to_string()))?
|
||||
{
|
||||
// local_name 取去前缀标签名("w:p" → "p"),与 Go 版 <w:p>/<w:t> 判定一致
|
||||
Event::Start(e) => {
|
||||
let name = e.local_name();
|
||||
let name = name.as_ref();
|
||||
if name == b"p".as_slice() {
|
||||
in_para = true;
|
||||
cur.clear();
|
||||
} else if name == b"t".as_slice() && in_para {
|
||||
in_text = true;
|
||||
}
|
||||
}
|
||||
Event::End(e) => {
|
||||
let name = e.local_name();
|
||||
let name = name.as_ref();
|
||||
if name == b"t".as_slice() {
|
||||
in_text = false;
|
||||
} else if name == b"p".as_slice() && in_para {
|
||||
paras.push(cur.trim().to_string());
|
||||
in_para = false;
|
||||
}
|
||||
}
|
||||
Event::Text(e) => {
|
||||
if in_para && in_text {
|
||||
cur.push_str(&e.unescape().map_err(|x| SearchError::Xml(x.to_string()))?);
|
||||
}
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(paras)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! 文件列表收集:深度优先遍历目录,按选项过滤文件类型与递归深度。
|
||||
//!
|
||||
//! 与 Go 版 filepath.WalkDir 行为对齐:
|
||||
//! - 不跟随符号链接(walkdir 默认);
|
||||
//! - 跳过任意深度下名为 `.cache` 的子目录(本工具自己的缓存目录);
|
||||
//! - 单条目录项错误(权限等)静默跳过,不中断遍历。
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use super::SearchOptions;
|
||||
|
||||
/// 收集 root 下所有允许扫描的文件路径。
|
||||
pub fn collect_files(root: &Path, opts: &SearchOptions) -> Vec<PathBuf> {
|
||||
let mut builder = WalkDir::new(root);
|
||||
if !opts.include_subdirectories {
|
||||
builder = builder.max_depth(1);
|
||||
}
|
||||
builder
|
||||
.into_iter()
|
||||
// 排除搜索缓存目录本身(根目录自身即使叫 .cache 也保留)
|
||||
.filter_entry(|e| {
|
||||
!(e.depth() > 0 && e.file_type().is_dir() && e.file_name() == OsStr::new(".cache"))
|
||||
})
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
.filter(|e| opts.ext_allowed(e.path()))
|
||||
// 统一规范化为绝对路径(Windows 下分隔符归一为反斜杠):
|
||||
// 用户传 "C:/dir" 或 "C:\dir" 得到同一缓存键,
|
||||
// 且与 Go 版 filepath.Join+Clean 的键格式对齐,保证跨版本缓存条目复用。
|
||||
.map(|e| {
|
||||
let p = e.into_path();
|
||||
std::path::absolute(&p).unwrap_or(p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 统一错误类型。
|
||||
//!
|
||||
//! 解析单个文件失败不会中断整次搜索(与 Go 版行为一致):
|
||||
//! worker 侧记录告警后跳过该文件,仅致命错误(如线程池无法创建)才向上返回。
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
/// 搜索过程中的错误类别。
|
||||
#[derive(Debug)]
|
||||
pub enum SearchError {
|
||||
/// 文件读写 / IO 错误
|
||||
Io(io::Error),
|
||||
/// OOXML(.docx/.xlsx 内部 zip)错误
|
||||
Zip(String),
|
||||
/// OOXML XML 解析错误
|
||||
Xml(String),
|
||||
/// Excel 工作簿解析错误(calamine)
|
||||
Excel(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for SearchError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SearchError::Io(e) => write!(f, "io error: {e}"),
|
||||
SearchError::Zip(m) => write!(f, "zip error: {m}"),
|
||||
SearchError::Xml(m) => write!(f, "xml error: {m}"),
|
||||
SearchError::Excel(m) => write!(f, "excel error: {m}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SearchError {}
|
||||
|
||||
impl From<io::Error> for SearchError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
SearchError::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// crate 内部统一 Result 别名。
|
||||
pub type Result<T> = std::result::Result<T, SearchError>;
|
||||
@@ -0,0 +1,105 @@
|
||||
//! 搜索历史:最近使用的目录 / 关键字候选(LRU),
|
||||
//! 供桌面版 GUI 使用;文件格式与 Go 桌面版完全兼容(`.cache/search_history.json`),
|
||||
//! 两版工具共用同一份历史文件。
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 目录候选最大保留条数(与 Go 桌面版一致)
|
||||
pub const MAX_DIRS: usize = 10;
|
||||
/// 关键字候选最大保留条数(与 Go 桌面版一致)
|
||||
pub const MAX_KEYWORDS: usize = 20;
|
||||
|
||||
/// 历史记录持久化结构。JSON 字段 dirs / keywords 与 Go 版同构。
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HistoryStore {
|
||||
pub dirs: Vec<String>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
|
||||
impl HistoryStore {
|
||||
/// 历史记录落盘路径:运行目录下 `.cache/`,与搜索缓存同目录。
|
||||
pub fn file_path() -> PathBuf {
|
||||
PathBuf::from(".cache").join("search_history.json")
|
||||
}
|
||||
|
||||
/// 读取历史;文件缺失或损坏时返回内置默认(目录候选 ".",关键字为空),
|
||||
/// 保证首次启动可用。
|
||||
pub fn load() -> Self {
|
||||
let mut store = HistoryStore {
|
||||
dirs: vec![".".to_string(), "./".to_string()],
|
||||
keywords: vec![],
|
||||
};
|
||||
if let Ok(data) = fs::read_to_string(Self::file_path()) {
|
||||
if let Ok(loaded) = serde_json::from_str::<HistoryStore>(&data) {
|
||||
store = loaded;
|
||||
}
|
||||
}
|
||||
if store.dirs.is_empty() {
|
||||
store.dirs.push(".".to_string());
|
||||
}
|
||||
store
|
||||
}
|
||||
|
||||
/// 整体覆写历史文件;写入失败静默忽略(历史只是便利项)。
|
||||
pub fn save(&self) {
|
||||
let _ = fs::create_dir_all(".cache");
|
||||
if let Ok(data) = serde_json::to_vec(self) {
|
||||
let _ = fs::write(Self::file_path(), data);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_dir(&mut self, item: &str) {
|
||||
add_history_item(&mut self.dirs, item, MAX_DIRS);
|
||||
}
|
||||
|
||||
pub fn push_keyword(&mut self, item: &str) {
|
||||
add_history_item(&mut self.keywords, item, MAX_KEYWORDS);
|
||||
}
|
||||
}
|
||||
|
||||
/// 将 item 移到 list 队首并去重,超出 max 条截断最旧的(LRU 语义,同 Go addHistoryItem)。
|
||||
pub fn add_history_item(list: &mut Vec<String>, item: &str, max: usize) {
|
||||
if item.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut new_list = Vec::with_capacity(max.min(16));
|
||||
new_list.push(item.to_string());
|
||||
for v in list.iter() {
|
||||
if v != item {
|
||||
new_list.push(v.clone());
|
||||
}
|
||||
}
|
||||
new_list.truncate(max);
|
||||
*list = new_list;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lru_semantics_match_go_desktop() {
|
||||
let mut list = vec!["a".to_string(), "b".to_string(), "c".to_string()];
|
||||
add_history_item(&mut list, "b", 3);
|
||||
assert_eq!(list, vec!["b", "a", "c"]);
|
||||
add_history_item(&mut list, "x", 3);
|
||||
assert_eq!(list, vec!["x", "b", "a"]); // 截断最旧的 c
|
||||
add_history_item(&mut list, "", 3);
|
||||
assert_eq!(list, vec!["x", "b", "a"]); // 空项忽略
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_schema_compatible_with_go() {
|
||||
let store = HistoryStore {
|
||||
dirs: vec![".".into(), "D:/data".into()],
|
||||
keywords: vec!["手机".into()],
|
||||
};
|
||||
let json = serde_json::to_string(&store).unwrap();
|
||||
assert!(json.starts_with(r#"{"dirs":"#) && json.contains(r#""keywords""#));
|
||||
let back: HistoryStore = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, store);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! # excel-search-rust
|
||||
//!
|
||||
//! 跨 Excel / Word / CSV / 文本 文件批量关键字检索工具(Rust 实现)。
|
||||
//!
|
||||
//! 本 crate 同时提供库入口与命令行入口(`src/main.rs`):
|
||||
//! - [`engine`]:并发搜索核心(目录遍历、解析器分发、匹配语义、文件级缓存)
|
||||
//! - [`error`]:统一错误类型
|
||||
//!
|
||||
//! 匹配语义与 Go 版(`go/internal/search`)对齐:
|
||||
//! 搜索文本与关键字均做 Unicode NFC 规范化;默认不区分大小写
|
||||
//! (Unicode 大小写折叠);完整单词匹配默认关闭,开启后要求关键字前后均为单词边界。
|
||||
|
||||
pub mod engine;
|
||||
pub mod error;
|
||||
pub mod history;
|
||||
+156
-15
@@ -1,21 +1,162 @@
|
||||
use std::env;
|
||||
//! excel-search-rust 命令行入口:并发检索目录下
|
||||
//! Excel / Word / CSV / 文本文件的关键字,输出人类可读列表或 JSON。
|
||||
//!
|
||||
//! 两种等价写法(与 Go 版 searcher 对齐):
|
||||
//!
|
||||
//! ```text
|
||||
//! excel-search-rust --dir <path> --keyword <kw> [--json] [--workers N] ...
|
||||
//! excel-search-rust <path> <kw>
|
||||
//! ```
|
||||
//!
|
||||
//! JSON 输出走 stdout;解析告警走 stderr,保证管道消费纯净(与 Go 版一致)。
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
println!("Excel Keyword Search Tool (Rust Implementation)");
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 3 {
|
||||
println!("Usage: excel-search-rust <dir_path> <keyword>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let dir_path = &args[1];
|
||||
let keyword = &args[2];
|
||||
println!("Searching in: {} for keyword: {}", dir_path, keyword);
|
||||
use clap::{Parser, ValueEnum};
|
||||
use excel_search_rust::engine::{self, FileType, SearchOptions};
|
||||
|
||||
if !Path::new(dir_path).exists() {
|
||||
eprintln!("Error: Directory does not exist: {}", dir_path);
|
||||
std::process::exit(1);
|
||||
/// 文件类型过滤取值(kebab-case 命令行值)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum FileTypeArg {
|
||||
All,
|
||||
Excel,
|
||||
Word,
|
||||
Csv,
|
||||
}
|
||||
|
||||
impl From<FileTypeArg> for FileType {
|
||||
fn from(v: FileTypeArg) -> Self {
|
||||
match v {
|
||||
FileTypeArg::All => FileType::All,
|
||||
FileTypeArg::Excel => FileType::Excel,
|
||||
FileTypeArg::Word => FileType::Word,
|
||||
FileTypeArg::Csv => FileType::Csv,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "excel-search-rust",
|
||||
version,
|
||||
about = "跨 Excel / Word / CSV / 文本 文件批量关键字检索工具(Rust 实现)"
|
||||
)]
|
||||
struct Cli {
|
||||
/// 搜索目录
|
||||
#[arg(short, long, default_value = ".")]
|
||||
dir: String,
|
||||
|
||||
/// 要查找的关键字
|
||||
#[arg(short, long, default_value = "")]
|
||||
keyword: String,
|
||||
|
||||
/// 以 JSON 格式输出结果(便于脚本处理)
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
/// 并发 worker 数
|
||||
#[arg(long, env = "EXCEL_SEARCH_WORKERS", default_value_t = 8)]
|
||||
workers: usize,
|
||||
|
||||
/// 区分大小写(默认不区分)
|
||||
#[arg(long, default_value_t = false)]
|
||||
case_sensitive: bool,
|
||||
|
||||
/// 完整单词匹配(默认关闭,即输出模糊子串匹配)
|
||||
#[arg(long, default_value_t = false)]
|
||||
whole_word: bool,
|
||||
|
||||
/// 包含子目录(默认开启;传 --include-subdirectories=false 关闭)
|
||||
#[arg(long, default_value_t = true)]
|
||||
include_subdirectories: bool,
|
||||
|
||||
/// 文件类型过滤
|
||||
#[arg(long, value_enum, default_value_t = FileTypeArg::All)]
|
||||
file_type: FileTypeArg,
|
||||
|
||||
/// 位置参数写法:搜索目录
|
||||
#[arg(value_name = "DIR")]
|
||||
path: Option<String>,
|
||||
|
||||
/// 位置参数写法:关键字
|
||||
#[arg(value_name = "KEYWORD")]
|
||||
keyword_pos: Option<String>,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// 关键字缺省时尝试位置参数写法:excel-search-rust <path> <keyword>;
|
||||
// 仍取不到则打印用法退出(与 Go 版 searcher 行为一致)。
|
||||
let (dir, keyword) = match resolve_inputs(&cli) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
print_usage();
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
|
||||
let opts = SearchOptions {
|
||||
case_sensitive: cli.case_sensitive,
|
||||
whole_word: cli.whole_word,
|
||||
include_subdirectories: cli.include_subdirectories,
|
||||
file_type: FileType::from(cli.file_type),
|
||||
};
|
||||
|
||||
println!(
|
||||
"[Info] Starting search in '{}' for keyword '{}' (case-sensitive={} whole-word={} subdirs={} file-type={})...",
|
||||
dir,
|
||||
keyword,
|
||||
opts.case_sensitive,
|
||||
opts.whole_word,
|
||||
opts.include_subdirectories,
|
||||
opts.file_type.as_str()
|
||||
);
|
||||
let start = Instant::now();
|
||||
|
||||
let results = engine::search_directory(Path::new(&dir), &keyword, cli.workers, &opts);
|
||||
let duration = start.elapsed();
|
||||
|
||||
if cli.json {
|
||||
// 结果序列化失败理论上不可能(纯 String 字段),兜底输出空数组
|
||||
match serde_json::to_string_pretty(&results) {
|
||||
Ok(s) => println!("{s}"),
|
||||
Err(_) => println!("[]"),
|
||||
}
|
||||
} else {
|
||||
println!(
|
||||
"\n[Result] Found {} matches in {:.2} seconds:",
|
||||
results.len(),
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
for (i, r) in results.iter().enumerate() {
|
||||
println!(
|
||||
"[{}] File: {} | Sheet: {} | Loc: {} | Content: {}",
|
||||
i + 1,
|
||||
r.file_path,
|
||||
r.sheet_name,
|
||||
r.location,
|
||||
r.cell_content
|
||||
);
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn resolve_inputs(cli: &Cli) -> Option<(String, String)> {
|
||||
if !cli.keyword.is_empty() {
|
||||
return Some((cli.dir.clone(), cli.keyword.clone()));
|
||||
}
|
||||
if let (Some(p), Some(k)) = (cli.path.clone(), cli.keyword_pos.clone()) {
|
||||
return Some((p, k));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// TODO: Implement Rayon parallel walker and Calamine Excel parser
|
||||
fn print_usage() {
|
||||
eprintln!("Usage: excel-search-rust --dir <path> --keyword <keyword> [--json] [--workers N]");
|
||||
eprintln!(" or: excel-search-rust <path> <keyword>");
|
||||
eprintln!("Options: --case-sensitive --whole-word --include-subdirectories=false --file-type (all|excel|word|csv)");
|
||||
}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,281 @@
|
||||
//! 引擎级集成测试:真实 xlsx 夹具 + 运行时生成的 docx/txt/csv,
|
||||
//! 覆盖默认模糊匹配、大小写、完整单词、类型过滤、递归开关与文件级缓存。
|
||||
//! 与 Go 版 scanner_test.go 的断言语义一一对应。
|
||||
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use excel_search_rust::engine::{self, FileType, SearchOptions};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// 每个测试独立的临时目录:数据目录 + 显式缓存目录(避免并行测试共享环境变量)。
|
||||
struct Sandbox {
|
||||
_tmp: TempDir,
|
||||
data: PathBuf,
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
impl Sandbox {
|
||||
fn new() -> Self {
|
||||
let tmp = TempDir::new().expect("temp dir");
|
||||
let data = tmp.path().join("data");
|
||||
let cache = tmp.path().join("cache");
|
||||
fs::create_dir_all(&data).unwrap();
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
Sandbox {
|
||||
_tmp: tmp,
|
||||
data,
|
||||
cache,
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_xlsx(&self, name: &str) {
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/search_sample.xlsx");
|
||||
fs::copy(&src, self.data.join(name)).expect("copy fixture");
|
||||
}
|
||||
|
||||
fn write_text(&self, rel: &str, content: &str) {
|
||||
let p = self.data.join(rel);
|
||||
if let Some(dir) = p.parent() {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
fs::write(&p, content).unwrap();
|
||||
}
|
||||
|
||||
fn write_docx(&self, name: &str, paragraphs: &[&str]) {
|
||||
fs::write(self.data.join(name), docx_bytes(paragraphs)).unwrap();
|
||||
}
|
||||
|
||||
fn run(&self, keyword: &str, opts: &SearchOptions) -> Vec<engine::SearchResult> {
|
||||
engine::search_directory_with_cache(&self.data, keyword, 4, opts, Some(&self.cache))
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成最小可解析的 .docx(word/document.xml),段落内容与 writeTestDocx(Go) 等价。
|
||||
fn docx_bytes(paragraphs: &[&str]) -> Vec<u8> {
|
||||
use zip::write::FileOptions;
|
||||
use zip::{CompressionMethod, ZipWriter};
|
||||
|
||||
let mut zw = ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = FileOptions::default().compression_method(CompressionMethod::Deflated);
|
||||
zw.start_file("word/document.xml", options).unwrap();
|
||||
let mut xml = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\
|
||||
<w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>",
|
||||
);
|
||||
for p in paragraphs {
|
||||
xml.push_str("<w:p><w:r><w:t>");
|
||||
xml.push_str(p);
|
||||
xml.push_str("</w:t></w:r></w:p>");
|
||||
}
|
||||
xml.push_str("</w:body></w:document>");
|
||||
zw.write_all(xml.as_bytes()).unwrap();
|
||||
zw.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn locations(results: &[engine::SearchResult]) -> Vec<String> {
|
||||
let mut v: Vec<String> = results
|
||||
.iter()
|
||||
.map(|r| format!("{}!{}", r.sheet_name, r.location))
|
||||
.collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
// ---------- Excel ----------
|
||||
|
||||
#[test]
|
||||
fn excel_fuzzy_default() {
|
||||
let sb = Sandbox::new();
|
||||
sb.copy_xlsx("sample.xlsx");
|
||||
let opts = SearchOptions::default(); // 模糊 + 不区分大小写
|
||||
|
||||
let res = sb.run("手机", &opts);
|
||||
assert_eq!(locations(&res), vec!["Sheet1!A1", "Sheet1!A2"]);
|
||||
assert!(res.iter().all(|r| r.file_type == "xlsx"));
|
||||
|
||||
// 默认模糊:前缀 "Alph" 也能命中 Alpha/alpha 两个单元格
|
||||
assert_eq!(sb.run("Alph", &opts).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_case_sensitivity() {
|
||||
let sb = Sandbox::new();
|
||||
sb.copy_xlsx("sample.xlsx");
|
||||
|
||||
// 默认不区分大小写:小写 alpha 命中 Alpha Beta 与 alpha gamma
|
||||
let ci = SearchOptions::default();
|
||||
assert_eq!(sb.run("alpha", &ci).len(), 2);
|
||||
|
||||
// 区分大小写:精确形态各自只命中一处
|
||||
let cs = SearchOptions {
|
||||
case_sensitive: true,
|
||||
..Default::default()
|
||||
};
|
||||
let res = sb.run("alpha", &cs);
|
||||
assert_eq!(locations(&res), vec!["Sheet1!A4"]);
|
||||
let res = sb.run("Alpha", &cs);
|
||||
assert_eq!(locations(&res), vec!["Sheet1!A3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_whole_word() {
|
||||
let sb = Sandbox::new();
|
||||
sb.copy_xlsx("sample.xlsx");
|
||||
let whole = SearchOptions {
|
||||
whole_word: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// 完整单词:前缀 "Alph" 不构成边界 → 不命中
|
||||
assert_eq!(sb.run("Alph", &whole).len(), 0);
|
||||
// "alpha"(大小写折叠后)在 A3/A4 均处于边界 → 命中两处
|
||||
assert_eq!(sb.run("alpha", &whole).len(), 2);
|
||||
}
|
||||
|
||||
// ---------- 文本 / CSV ----------
|
||||
|
||||
#[test]
|
||||
fn text_and_csv_filter() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_text("notes.txt", "记录:手机。\n另一行\n");
|
||||
sb.write_text("data.csv", "name,phone\n手机,123\n");
|
||||
|
||||
let all = SearchOptions::default();
|
||||
let res = sb.run("手机", &all);
|
||||
assert_eq!(res.len(), 2);
|
||||
assert!(res
|
||||
.iter()
|
||||
.any(|r| r.file_type == "txt" && r.location == "Line 1"));
|
||||
assert!(res
|
||||
.iter()
|
||||
.any(|r| r.file_type == "csv" && r.location == "Line 2"));
|
||||
|
||||
// Csv 过滤仅含 .csv,不含 .txt(与 Go 版一致)
|
||||
let csv = SearchOptions {
|
||||
file_type: FileType::Csv,
|
||||
..Default::default()
|
||||
};
|
||||
let res = sb.run("手机", &csv);
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].file_type, "csv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_word_cjk_rule() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_text("goods.txt", "苹果手机壳");
|
||||
|
||||
// 默认模糊:汉字内部子串命中
|
||||
assert_eq!(sb.run("手机", &SearchOptions::default()).len(), 1);
|
||||
// 完整单词:汉字是字母,内部子串不命中
|
||||
let whole = SearchOptions {
|
||||
whole_word: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(sb.run("手机", &whole).len(), 0);
|
||||
}
|
||||
|
||||
// ---------- Word ----------
|
||||
|
||||
#[test]
|
||||
fn docx_paragraph_location() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_docx(
|
||||
"sample.docx",
|
||||
&["Intro paragraph", "这里是 手机。", "Tail paragraph"],
|
||||
);
|
||||
|
||||
let res = sb.run("手机", &SearchOptions::default());
|
||||
assert_eq!(res.len(), 1);
|
||||
assert_eq!(res[0].location, "Paragraph 2");
|
||||
assert_eq!(res[0].sheet_name, "Document");
|
||||
assert_eq!(res[0].file_type, "docx");
|
||||
assert_eq!(res[0].cell_content, "这里是 手机。");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_docx_is_skipped() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_docx("ok.docx", &["这里是 手机。"]);
|
||||
fs::write(sb.data.join("bad.docx"), b"not a zip").unwrap();
|
||||
|
||||
// 损坏文件只告警跳过,不 panic、不影响正常文件
|
||||
let res = sb.run("手机", &SearchOptions::default());
|
||||
assert_eq!(res.len(), 1);
|
||||
}
|
||||
|
||||
// ---------- 递归 / 缓存 ----------
|
||||
|
||||
#[test]
|
||||
fn subdirectories_toggle() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_text("root.txt", "手机");
|
||||
sb.write_text("nested/deep.txt", "手机");
|
||||
|
||||
let rec = SearchOptions::default();
|
||||
assert_eq!(sb.run("手机", &rec).len(), 2);
|
||||
|
||||
let top = SearchOptions {
|
||||
include_subdirectories: false,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(sb.run("手机", &top).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_cache_reuse_and_invalidation() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_text("notes.txt", "手机 一号\n手机 二号\n");
|
||||
|
||||
// 首搜生成缓存文件
|
||||
let opts = SearchOptions::default();
|
||||
assert_eq!(sb.run("手机", &opts).len(), 2);
|
||||
let cache_files: Vec<_> = fs::read_dir(&sb.cache)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().extension().map(|x| x == "json").unwrap_or(false))
|
||||
.collect();
|
||||
assert_eq!(cache_files.len(), 1, "缓存应按根目录聚合成单一文件");
|
||||
|
||||
// 二次搜索(缓存命中)结果一致
|
||||
assert_eq!(sb.run("手机", &opts).len(), 2);
|
||||
|
||||
// 文件改动(大小变化)→ 缓存失效,结果更新
|
||||
sb.write_text("notes.txt", "手机 一号\n手机 二号\n手机 三号\n");
|
||||
assert_eq!(sb.run("手机", &opts).len(), 3);
|
||||
|
||||
// 不同选项组合(完整单词开关)→ 缓存键隔离,互不串用
|
||||
let whole = SearchOptions {
|
||||
whole_word: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(sb.run("手机", &whole).len(), 3); // "手机 一号" 空格构成边界,仍全命中
|
||||
let phone = SearchOptions {
|
||||
case_sensitive: true,
|
||||
whole_word: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(sb.run("Phone", &phone).len(), 0); // 另一组合不受污染
|
||||
}
|
||||
|
||||
// ---------- 其他 ----------
|
||||
|
||||
#[test]
|
||||
fn empty_keyword_matches_nothing_and_missing_dir_is_empty() {
|
||||
let sb = Sandbox::new();
|
||||
sb.write_text("a.txt", "手机");
|
||||
assert_eq!(sb.run("", &SearchOptions::default()).len(), 0);
|
||||
|
||||
// 不存在的目录:不报错,返回空结果(与 Go 版一致)
|
||||
let missing = sb.data.join("no_such_dir");
|
||||
let res = engine::search_directory_with_cache(
|
||||
&missing,
|
||||
"手机",
|
||||
2,
|
||||
&SearchOptions::default(),
|
||||
Some(&sb.cache),
|
||||
);
|
||||
assert!(res.is_empty());
|
||||
}
|
||||
Reference in new issue
Block a user