rust版本完成

This commit is contained in:
maxf committed 2026-09-21 21:04:43 +08:00
1 parent eb3855f434
commit 8594a0167b
19 files changed
+7046 -63

No files matched your search

+2
View File
@@ -1,3 +1,5 @@
python/build/
.vscode/
go/frontend/package-lock.json
rust/target/
go/.cache/
+1 -1
View File
@@ -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` | 全部 | 搜索缓存目录 |
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 894 KiB

+4840
View File
File diff suppressed because it is too large. Load diff
+25 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
+522
View File
@@ -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");
}
}
+81
View File
@@ -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);
}
}
+191
View File
@@ -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"));
}
}
+292
View File
@@ -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,
}
}
}
}
+184
View File
@@ -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)
}
+38
View File
@@ -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()
}
+42
View File
@@ -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>;
+105
View File
@@ -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);
}
}
+15
View File
@@ -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
View File
@@ -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)");
}
Binary file not shown.
+281
View File
@@ -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());
}