From 21da3b293019761c6dc4d7a78ae449bc3708e265 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=88=98=E8=88=AA=E5=AE=87?= <3364451258@qq.com>
Date: Sat, 30 May 2026 17:31:04 +0800
Subject: [PATCH] =?UTF-8?q?fix:=20v5.1=20=E4=BB=A3=E7=A0=81=E5=AE=A1?=
=?UTF-8?q?=E6=9F=A5=E4=BF=AE=E5=A4=8D=20=E2=80=94=20ESLint/CSV/=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E9=9A=94=E7=A6=BB/CLI=20=E5=8E=BB=E9=87=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- ESLint: 迁移到 flat config ignores,删除已废弃的 .eslintignore
- CSV: Rust/TS 格式对齐,统一 type,path,enabled 3 列
- JSON: 导入导出统一为 {path, enabled} 对象格式
- scanner: 移除未使用的 max_threads 死代码 + TempDirGuard 测试清理
- profiles: rename_profile 添加目标存在检查
- CLI: 抽取 load_operate_save helper,简化 cmd_remove/cmd_edit
- PathTable: 抽取 usePathValidation hook,消除 set-state-in-effect
- 测试隔离: disabled/profiles 通过 #[cfg(test)] 重定向到 temp dir
- toolchain: 新增 rust-toolchain.toml 固定 stable-x86_64-pc-windows-gnu
- docs: 更新 CLAUDE.md/README.md 测试计数 + 架构树
Co-Authored-By: Claude Opus 4.7
---
.eslintignore | 6 -
.gitignore | 1 +
CLAUDE.md | 6 +-
README.md | 9 +-
cli/src/main.rs | 72 ++++----
core/src/disabled.rs | 6 +
core/src/fs.rs | 228 +++++++++++++++++++------
core/src/profiles.rs | 11 +-
core/src/scanner.rs | 26 ++-
docs/REMAINING-ISSUES.md | 35 ++++
eslint.config.js | 2 +-
rust-toolchain.toml | 2 +
src/components/path-list/PathTable.tsx | 115 +------------
src/hooks/use-path-validation.ts | 125 ++++++++++++++
14 files changed, 430 insertions(+), 214 deletions(-)
delete mode 100644 .eslintignore
create mode 100644 docs/REMAINING-ISSUES.md
create mode 100644 rust-toolchain.toml
create mode 100644 src/hooks/use-path-validation.ts
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index a25faf3..0000000
--- a/.eslintignore
+++ /dev/null
@@ -1,6 +0,0 @@
-node_modules/
-dist/
-target/
-test-results/
-e2e/
-*.config.*
diff --git a/.gitignore b/.gitignore
index 792a220..b24c0f3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,7 @@ dist-ssr
*.sln
*.sw?
.claude/
+.codegraph/
CLAUDE.md
e2e/debug-screenshot.png
test-results/
diff --git a/CLAUDE.md b/CLAUDE.md
index 0a193bb..96cf564 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -65,11 +65,13 @@ PathEditor/
│ │ ├── toolbar/ # ToolBar、ActionButtons、UndoRedoButtons
│ │ ├── dialogs/ # PathEdit、Help、Import、Analyze、Profile
│ │ └── ui/ # Modal、buttons
-│ ├── hooks/ # useAppActions、useKeyboard
+│ ├── hooks/ # useAppActions、useKeyboard、usePathValidation
│ ├── i18n/ # zh-CN / en
│ └── config/ # default.json
+├── docs/ # REMaining-ISSUES 等审查文档
├── tests/unit/ # Vitest 前端单元测试
├── e2e/ # Playwright E2E 测试
+├── rust-toolchain.toml # 固定工具链版本
└── Cargo.toml # Workspace 根 + [workspace.package]
```
@@ -144,7 +146,7 @@ patheditor profile {list|save|load|apply|delete|rename}
## 关键约束
- **TypeScript**:`strict: true`,零编译错误
-- **Rust 工具链**:`stable-x86_64-pc-windows-gnu`(项目已设 override)
+- **Rust 工具链**:`stable-x86_64-pc-windows-gnu`(`rust-toolchain.toml` 强制)
- **MinGW 兼容**:`.cargo/config.toml` 添加 `-lmcfgthread`(GCC 15.2.0 运行时)
- **运行权限**:需要管理员权限才能编辑系统 PATH,非管理员自动进入只读模式
- **构建产物**:NSIS 安装包,约 8MB
diff --git a/README.md b/README.md
index f5a23d9..b22028f 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -259,8 +259,8 @@ npx tauri build
| 国际化 | i18next |
| 桌面框架 | Tauri 2.x |
| 核心库 | Rust workspace (core + gui + cli) |
-| 前端测试 | Vitest (72 个测试) |
-| Rust 测试 | cargo test (10 个测试) |
+| 前端测试 | Vitest (100 个测试) |
+| Rust 测试 | cargo test (57 个测试) |
| 构建 | Vite + Cargo |
| 打包 | NSIS |
@@ -282,10 +282,11 @@ src/ # React 前端
├── core/ # 纯逻辑 — 零框架依赖
├── store/ # Zustand 状态管理
├── components/ # UI 组件
-├── hooks/ # useAppActions、useKeyboard
+├── hooks/ # useAppActions、useKeyboard、usePathValidation
├── i18n/ # zh-CN / en
└── config/ # default.json
tests/unit/ # 前端单元测试
+docs/ # 审查文档
```
## 快捷键
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 51ddd54..616a007 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -191,6 +191,29 @@ fn load_and_save(system: bool, f: impl FnOnce(Vec) -> Vec) {
verify_and_save(target, &list, new_list);
}
+/// 加载、检查索引、操作、验证、保存的通用模式
+/// `operate` 接收路径列表(包含原始列表)和要操作的索引,返回新列表和打印消息
+fn load_operate_save(
+ system: bool,
+ index: usize,
+ operate: impl FnOnce(Vec, usize) -> (Vec, String),
+) {
+ let target = ensure_single_target(system, false);
+ let list = if target == "system" {
+ core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
+ } else {
+ core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
+ };
+ if index >= list.len() {
+ exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len()));
+ }
+ let original = list.clone();
+ let (new_list, msg) = operate(list, index);
+ verify_and_save(target, &original, new_list);
+ println!("{msg}");
+ core::system::broadcast_env_change();
+}
+
// ── 命令实现 ──
fn cmd_list(system: bool, user: bool, json_out: bool) {
@@ -237,37 +260,17 @@ fn cmd_add(path: String, system: bool, user: bool) {
}
fn cmd_remove(index: usize, system: bool) {
- let target = ensure_single_target(system, false);
- let mut list = if target == "system" {
- core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
- } else {
- core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
- };
- let original = list.clone();
- if index >= list.len() {
- exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len()));
- }
- let removed = list.remove(index);
- verify_and_save(target, &original, list);
- println!("已删除: {removed}");
- core::system::broadcast_env_change();
+ load_operate_save(system, index, |mut list, idx| {
+ let removed = list.remove(idx);
+ (list, format!("已删除: {removed}"))
+ });
}
fn cmd_edit(index: usize, new_path: String, system: bool) {
- let target = ensure_single_target(system, false);
- let mut list = if target == "system" {
- core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e))
- } else {
- core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e))
- };
- if index >= list.len() {
- exit_err(&format!("索引 {index} 超出范围 (共 {} 条)", list.len()));
- }
- let original = list.clone();
- let old = std::mem::replace(&mut list[index], new_path.clone());
- verify_and_save(target, &original, list);
- println!("已编辑: {old} → {new_path}");
- core::system::broadcast_env_change();
+ load_operate_save(system, index, |mut list, idx| {
+ let old = std::mem::replace(&mut list[idx], new_path.clone());
+ (list, format!("已编辑: {old} → {new_path}"))
+ });
}
fn cmd_move(index: usize, steps: usize, system: bool, up: bool) {
@@ -389,23 +392,26 @@ fn cmd_toggle(index: usize, system: bool, user: bool, enable: bool) {
fn cmd_import(file: String, target: String) {
let content = core::fs::read_text_file(&file).unwrap_or_else(|e| exit_err(&e));
- let (sys, usr) = core::fs::import_paths(&file, &content).unwrap_or_else(|e| exit_err(&e));
+ let (sys_entries, usr_entries) =
+ core::fs::import_paths(&file, &content).unwrap_or_else(|e| exit_err(&e));
+ let sys_paths: Vec = sys_entries.into_iter().map(|e| e.path).collect();
+ let usr_paths: Vec = usr_entries.into_iter().map(|e| e.path).collect();
match target.as_str() {
"system" => {
let orig = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
- verify_and_save("system", &orig, sys);
+ verify_and_save("system", &orig, sys_paths);
println!("已导入到系统 PATH");
}
"user" => {
let orig = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
- verify_and_save("user", &orig, usr);
+ verify_and_save("user", &orig, usr_paths);
println!("已导入到用户 PATH");
}
_ => {
let orig_sys = core::registry::load_system_paths().unwrap_or_else(|e| exit_err(&e));
let orig_usr = core::registry::load_user_paths().unwrap_or_else(|e| exit_err(&e));
- verify_and_save("system", &orig_sys, sys);
- verify_and_save("user", &orig_usr, usr);
+ verify_and_save("system", &orig_sys, sys_paths);
+ verify_and_save("user", &orig_usr, usr_paths);
println!("已导入到系统 + 用户 PATH");
}
}
diff --git a/core/src/disabled.rs b/core/src/disabled.rs
index ff66b78..90d6624 100644
--- a/core/src/disabled.rs
+++ b/core/src/disabled.rs
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
+#[cfg(not(test))]
fn disabled_file_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
@@ -10,6 +11,11 @@ fn disabled_file_path() -> PathBuf {
.join("disabled.json")
}
+#[cfg(test)]
+fn disabled_file_path() -> PathBuf {
+ std::env::temp_dir().join("patheditor_test_disabled.json")
+}
+
#[derive(Serialize, Deserialize, Default)]
struct DisabledState {
#[serde(default)]
diff --git a/core/src/fs.rs b/core/src/fs.rs
index 5bf5e0f..bb5ba35 100644
--- a/core/src/fs.rs
+++ b/core/src/fs.rs
@@ -1,12 +1,17 @@
// 注意:TS 端 src/core/import-export.ts 有对应的导入导出实现,
// 前端使用 TS 版(需 ImportDialog 交互),CLI 使用 Rust 版,修改时需同步两端。
-/// 过滤导入路径:去除空白、排除 null 字节和分号(PATH 分隔符冲突)
-fn sanitize_paths(paths: Vec) -> Vec {
- paths
+use crate::profiles::ProfilePathEntry;
+
+/// 过滤导入条目:去除空白、排除 null 字节和分号(PATH 分隔符冲突)
+fn sanitize_entries(entries: Vec) -> Vec {
+ entries
.into_iter()
- .map(|p| p.trim().to_string())
- .filter(|p| !p.is_empty() && !p.contains('\0') && !p.contains(';'))
+ .map(|e| ProfilePathEntry {
+ path: e.path.trim().to_string(),
+ enabled: e.enabled,
+ })
+ .filter(|e| !e.path.is_empty() && !e.path.contains('\0') && !e.path.contains(';'))
.collect()
}
@@ -35,8 +40,11 @@ pub fn read_text_file(path: &str) -> Result {
std::fs::read_to_string(path).map_err(|e| format!("无法读取文件: {}", e))
}
-/// 导入路径文件(JSON / CSV / TXT),返回 (系统路径, 用户路径)
-pub fn import_paths(path: &str, content: &str) -> Result<(Vec, Vec), String> {
+/// 导入路径文件(JSON / CSV / TXT),返回 (系统条目, 用户条目)
+pub fn import_paths(
+ path: &str,
+ content: &str,
+) -> Result<(Vec, Vec), String> {
let ext = std::path::Path::new(path)
.extension()
.map(|e| e.to_ascii_lowercase())
@@ -51,17 +59,39 @@ pub fn import_paths(path: &str, content: &str) -> Result<(Vec, Vec Result<(Vec, Vec), String> {
+fn import_json(content: &str) -> Result<(Vec, Vec), String> {
+ #[derive(serde::Deserialize)]
+ struct ImportItem {
+ path: String,
+ #[serde(default = "default_true")]
+ enabled: bool,
+ }
+ fn default_true() -> bool {
+ true
+ }
+
#[derive(serde::Deserialize)]
struct ImportData {
#[serde(default)]
- system: Vec,
+ system: Vec,
#[serde(default)]
- user: Vec,
+ user: Vec,
}
let data: ImportData =
serde_json::from_str(content).map_err(|e| format!("JSON 解析失败: {}", e))?;
- Ok((sanitize_paths(data.system), sanitize_paths(data.user)))
+ let into_entries = |items: Vec| -> Vec {
+ items
+ .into_iter()
+ .map(|i| ProfilePathEntry {
+ path: i.path,
+ enabled: i.enabled,
+ })
+ .collect()
+ };
+ Ok((
+ sanitize_entries(into_entries(data.system)),
+ sanitize_entries(into_entries(data.user)),
+ ))
}
/// 解析 CSV 行,支持引号包裹的字段(RFC 4180 子集)
@@ -97,7 +127,9 @@ fn parse_csv_line(line: &str) -> Vec {
fields
}
-fn import_csv(content: &str) -> Result<(Vec, Vec), String> {
+fn import_csv(
+ content: &str,
+) -> Result<(Vec, Vec), String> {
let mut sys = Vec::new();
let mut usr = Vec::new();
let mut first = true;
@@ -113,7 +145,7 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> {
if let Some(stripped) = trimmed.strip_prefix('\u{FEFF}') {
trimmed = stripped;
}
- // 跳过 header 行 "type,path"
+ // 跳过 header 行,兼容 type,path 和 type,path,enabled 两种格式
let header_fields = parse_csv_line(trimmed);
if header_fields.len() >= 2 {
let c0 = header_fields[0].trim().to_lowercase();
@@ -126,9 +158,16 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> {
let fields = parse_csv_line(trimmed);
if fields.len() >= 2 {
+ let path = fields[1].trim().to_string();
+ let enabled = if fields.len() >= 3 {
+ fields[2].trim().to_lowercase() != "false"
+ } else {
+ true
+ };
+ let entry = ProfilePathEntry { path, enabled };
match fields[0].trim().to_lowercase().as_str() {
- "system" | "sys" => sys.push(fields[1].trim().to_string()),
- "user" | "usr" => usr.push(fields[1].trim().to_string()),
+ "system" | "sys" => sys.push(entry),
+ "user" | "usr" => usr.push(entry),
_ => {
log::warn!("import_csv: 无法识别的类型字段,已跳过: {trimmed}");
}
@@ -137,47 +176,57 @@ fn import_csv(content: &str) -> Result<(Vec, Vec), String> {
log::warn!("import_csv: 格式不正确(缺逗号),已跳过: {trimmed}");
}
}
- let sys = sanitize_paths(sys);
- let usr = sanitize_paths(usr);
+ let sys = sanitize_entries(sys);
+ let usr = sanitize_entries(usr);
if sys.is_empty() && usr.is_empty() {
return Err("CSV 文件中未找到有效路径".into());
}
Ok((sys, usr))
}
-fn import_txt(content: &str) -> Result<(Vec, Vec), String> {
- let paths: Vec = content
+fn import_txt(content: &str) -> Result<(Vec, Vec), String> {
+ let entries: Vec = content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
+ .map(|path| ProfilePathEntry {
+ path,
+ enabled: true,
+ })
.collect();
- let paths = sanitize_paths(paths);
- if paths.is_empty() {
+ let entries = sanitize_entries(entries);
+ if entries.is_empty() {
return Err("TXT 文件中未找到路径".into());
}
// TXT 格式全部导入为用户路径
- Ok((vec![], paths))
+ Ok((vec![], entries))
}
/// 导出 PATH 为指定格式字符串
pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result {
match format {
"json" => {
+ let to_entries = |paths: &[String]| -> Vec {
+ paths
+ .iter()
+ .map(|p| serde_json::json!({"path": p, "enabled": true}))
+ .collect()
+ };
let data = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"timestamp": chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
- "system": sys,
- "user": usr,
+ "system": to_entries(sys),
+ "user": to_entries(usr),
});
Ok(serde_json::to_string_pretty(&data).expect("JSON 序列化 Value 不应失败"))
}
"csv" => {
- let mut out = String::from("type,path\n");
+ let mut out = String::from("type,path,enabled\n");
for p in sys {
- out.push_str(&format!("system,{}\n", p));
+ out.push_str(&format!("system,{},true\n", p));
}
for p in usr {
- out.push_str(&format!("user,{}\n", p));
+ out.push_str(&format!("user,{},true\n", p));
}
Ok(out)
}
@@ -205,12 +254,30 @@ pub fn export_paths(sys: &[String], usr: &[String], format: &str) -> Result ProfilePathEntry {
+ ProfilePathEntry {
+ path: path.into(),
+ enabled: true,
+ }
+ }
+
+ fn entry_disabled(path: &str) -> ProfilePathEntry {
+ ProfilePathEntry {
+ path: path.into(),
+ enabled: false,
+ }
+ }
+
#[test]
fn import_json_valid() {
- let json = r#"{"system": ["C:\\sys1", "C:\\sys2"], "user": ["D:\\usr1"]}"#;
+ let json = r#"{"system": [{"path": "C:\\sys1"}, {"path": "C:\\sys2"}], "user": [{"path": "D:\\usr1"}]}"#;
let (sys, usr) = import_json(json).unwrap();
- assert_eq!(sys, vec!["C:\\sys1", "C:\\sys2"]);
- assert_eq!(usr, vec!["D:\\usr1"]);
+ assert_eq!(sys.len(), 2);
+ assert_eq!(sys[0].path, "C:\\sys1");
+ assert!(sys[0].enabled);
+ assert_eq!(sys[1].path, "C:\\sys2");
+ assert_eq!(usr.len(), 1);
+ assert_eq!(usr[0].path, "D:\\usr1");
}
#[test]
@@ -219,6 +286,15 @@ mod tests {
assert!(sys.is_empty() && usr.is_empty());
}
+ #[test]
+ fn import_json_disabled_entry() {
+ let json = r#"{"system": [{"path": "C:\\on", "enabled": true}, {"path": "C:\\off", "enabled": false}]}"#;
+ let (sys, _) = import_json(json).unwrap();
+ assert_eq!(sys.len(), 2);
+ assert!(sys[0].enabled);
+ assert!(!sys[1].enabled);
+ }
+
#[test]
fn import_json_missing_fields() {
let (sys, usr) = import_json(r#"{}"#).unwrap();
@@ -228,16 +304,20 @@ mod tests {
#[test]
fn import_csv_valid() {
let csv = "type,path\nsystem,C:\\sys1\nuser,D:\\usr1\n";
- let (sys, _usr) = import_csv(csv).unwrap();
- assert_eq!(sys, vec!["C:\\sys1"]);
- assert_eq!(_usr, vec!["D:\\usr1"]);
+ let (sys, usr) = import_csv(csv).unwrap();
+ assert_eq!(sys.len(), 1);
+ assert_eq!(sys[0].path, "C:\\sys1");
+ assert!(sys[0].enabled);
+ assert_eq!(usr.len(), 1);
+ assert_eq!(usr[0].path, "D:\\usr1");
+ assert!(usr[0].enabled);
}
#[test]
fn import_csv_with_bom() {
let csv = "\u{FEFF}type,path\nsystem,C:\\sys1\n";
let (sys, _) = import_csv(csv).unwrap();
- assert_eq!(sys, vec!["C:\\sys1"]);
+ assert_eq!(sys[0].path, "C:\\sys1");
}
#[test]
@@ -249,8 +329,27 @@ mod tests {
fn import_csv_alternate_type_names() {
let csv = "type,path\nsys,D:\\a\nusr,D:\\b\n";
let (sys, usr) = import_csv(csv).unwrap();
- assert_eq!(sys, vec!["D:\\a"]);
- assert_eq!(usr, vec!["D:\\b"]);
+ assert_eq!(sys[0].path, "D:\\a");
+ assert_eq!(usr[0].path, "D:\\b");
+ }
+
+ #[test]
+ fn import_csv_reads_enabled_column() {
+ let csv = "type,path,enabled\nsystem,C:\\ok,true\nsystem,C:\\disabled,false\n";
+ let (sys, _) = import_csv(csv).unwrap();
+ assert_eq!(sys.len(), 2);
+ assert_eq!(sys[0].path, "C:\\ok");
+ assert!(sys[0].enabled);
+ assert_eq!(sys[1].path, "C:\\disabled");
+ assert!(!sys[1].enabled);
+ }
+
+ #[test]
+ fn import_csv_enabled_defaults_true() {
+ // 2 列格式(无 enabled 列)默认为 true
+ let csv = "type,path\nsystem,C:\\x\n";
+ let (sys, _) = import_csv(csv).unwrap();
+ assert!(sys[0].enabled);
}
#[test]
@@ -259,16 +358,18 @@ mod tests {
let usr: Vec = vec![];
let exported = export_paths(&sys, &usr, "json").unwrap();
let parsed: serde_json::Value = serde_json::from_str(&exported).unwrap();
- assert_eq!(parsed["system"][0], "C:\\a");
+ assert_eq!(parsed["system"][0]["path"], "C:\\a");
+ assert_eq!(parsed["system"][0]["enabled"], true);
}
#[test]
- fn export_csv_roundtrip() {
+ fn export_csv_includes_enabled_column() {
let sys = vec!["C:\\a".into()];
let usr = vec!["D:\\b".into()];
let exported = export_paths(&sys, &usr, "csv").unwrap();
- assert!(exported.contains("system,C:\\a"));
- assert!(exported.contains("user,D:\\b"));
+ assert!(exported.starts_with("type,path,enabled"));
+ assert!(exported.contains("system,C:\\a,true"));
+ assert!(exported.contains("user,D:\\b,true"));
}
#[test]
@@ -287,14 +388,16 @@ mod tests {
#[test]
fn import_paths_detects_format() {
let (sys, _) = import_paths("test.csv", "type,path\nsystem,C:\\x\n").unwrap();
- assert_eq!(sys, vec!["C:\\x"]);
+ assert_eq!(sys[0].path, "C:\\x");
}
#[test]
fn import_paths_txt_to_user() {
let (sys, usr) = import_paths("test.txt", "C:\\x\nD:\\y\n").unwrap();
assert!(sys.is_empty());
- assert_eq!(usr, vec!["C:\\x", "D:\\y"]);
+ assert_eq!(usr.len(), 2);
+ assert_eq!(usr[0].path, "C:\\x");
+ assert_eq!(usr[1].path, "D:\\y");
}
#[test]
@@ -311,30 +414,42 @@ mod tests {
}
#[test]
- fn import_json_filters_null_byte_paths() {
- // sanitize_paths 作为额外防线
- let paths = vec!["C:\\safe".into(), "C:\\bad\0path".into()];
- assert_eq!(sanitize_paths(paths), vec!["C:\\safe"]);
+ fn sanitize_entries_filters_null_byte_paths() {
+ let entries = vec![entry("C:\\safe"), entry("C:\\bad\0path")];
+ let result = sanitize_entries(entries);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].path, "C:\\safe");
}
#[test]
fn import_csv_filters_semicolon_paths() {
let csv = "type,path\nsystem,C:\\good\nsystem,C:\\bad;path\n";
let (sys, _) = import_csv(csv).unwrap();
- assert_eq!(sys, vec!["C:\\good"]);
+ assert_eq!(sys.len(), 1);
+ assert_eq!(sys[0].path, "C:\\good");
}
#[test]
fn import_txt_trims_and_filters() {
let txt = " C:\\trimmed \n\nC:\\bad\0path\n# comment\n";
let (_, usr) = import_txt(txt).unwrap();
- assert_eq!(usr, vec!["C:\\trimmed"]);
+ assert_eq!(usr.len(), 1);
+ assert_eq!(usr[0].path, "C:\\trimmed");
}
#[test]
- fn sanitize_paths_removes_empty_after_trim() {
- let result = sanitize_paths(vec![" ".into(), "C:\\ok".into()]);
- assert_eq!(result, vec!["C:\\ok"]);
+ fn sanitize_entries_removes_empty_after_trim() {
+ let result = sanitize_entries(vec![entry(" "), entry("C:\\ok")]);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].path, "C:\\ok");
+ }
+
+ #[test]
+ fn sanitize_entries_preserves_enabled_flag() {
+ let result = sanitize_entries(vec![entry_disabled("C:\\keep")]);
+ assert_eq!(result.len(), 1);
+ assert_eq!(result[0].path, "C:\\keep");
+ assert!(!result[0].enabled);
}
#[test]
@@ -362,6 +477,15 @@ mod tests {
fn import_csv_quoted_comma_path() {
let csv = "type,path\nsystem,\"C:\\Program Files, Inc\\bin\"\n";
let (sys, _) = import_csv(csv).unwrap();
- assert_eq!(sys, vec!["C:\\Program Files, Inc\\bin"]);
+ assert_eq!(sys[0].path, "C:\\Program Files, Inc\\bin");
+ }
+
+ #[test]
+ fn csv_roundtrip_preserves_enabled() {
+ let csv = "type,path,enabled\nsystem,C:\\on,true\nsystem,C:\\off,false\n";
+ let (sys, _) = import_csv(csv).unwrap();
+ assert_eq!(sys.len(), 2);
+ assert!(sys[0].enabled);
+ assert!(!sys[1].enabled);
}
}
diff --git a/core/src/profiles.rs b/core/src/profiles.rs
index 46627d2..371e865 100644
--- a/core/src/profiles.rs
+++ b/core/src/profiles.rs
@@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
+#[cfg(not(test))]
fn profiles_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
@@ -10,6 +11,11 @@ fn profiles_dir() -> PathBuf {
.join("profiles")
}
+#[cfg(test)]
+fn profiles_dir() -> PathBuf {
+ std::env::temp_dir().join("patheditor_test_profiles")
+}
+
fn validate_profile_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("配置名称不能为空".into());
@@ -155,9 +161,13 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
validate_profile_name(old_name)?;
validate_profile_name(new_name)?;
let old_path = profile_path(old_name);
+ let new_path = profile_path(new_name);
if !old_path.exists() {
return Err(format!("配置文件不存在: {}", old_name));
}
+ if old_path != new_path && new_path.exists() {
+ return Err(format!("目标配置名已存在: {}", new_name));
+ }
let mut data: ProfileData = serde_json::from_str(
&fs::read_to_string(&old_path).map_err(|e| format!("无法读取配置文件: {}", e))?,
@@ -167,7 +177,6 @@ pub fn rename_profile(old_name: &str, new_name: &str) -> Result<(), String> {
data.name = new_name.to_string();
data.modified = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string();
- let new_path = profile_path(new_name);
let json =
serde_json::to_string_pretty(&data).map_err(|e| format!("JSON 序列化失败: {}", e))?;
atomic_write(&new_path, &json).map_err(|e| format!("无法写入配置文件: {}", e))?;
diff --git a/core/src/scanner.rs b/core/src/scanner.rs
index f3c1e3e..ff6e77e 100644
--- a/core/src/scanner.rs
+++ b/core/src/scanner.rs
@@ -50,10 +50,6 @@ fn list_exes(dir: &str) -> Vec {
/// 并行遍历每个 PATH 目录,查找 .exe/.bat/.cmd/.com/.ps1 文件,
/// 标记出现在多个目录中的同名文件(后面的目录会被前面的「遮蔽」)
pub fn scan_conflicts(paths: Vec) -> Result, String> {
- // 并行扫描各目录(限制并发数)
- let max_threads = std::thread::available_parallelism()
- .map(|n| n.get())
- .unwrap_or(4);
let results: Vec<(usize, String, Vec)> = std::thread::scope(|s| {
let handles: Vec<_> = paths
.iter()
@@ -66,8 +62,6 @@ pub fn scan_conflicts(paths: Vec) -> Result, String>
.collect::, _>>()
})
.map_err(|e| format!("线程扫描失败: {}", e))?;
- // max_threads 用于限制 scope 外的并行度,实际线程由 scope 调度
- let _ = max_threads;
// 合并: exe_name (小写) → [(priority, dir)]
let mut map: HashMap> = HashMap::new();
@@ -155,13 +149,29 @@ mod tests {
use super::*;
use std::fs;
- fn make_temp_dir_with_exes(prefix: &str, exe_names: &[&str]) -> std::path::PathBuf {
+ struct TempDirGuard(std::path::PathBuf);
+
+ impl Drop for TempDirGuard {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+ }
+
+ impl std::ops::Deref for TempDirGuard {
+ type Target = std::path::PathBuf;
+ fn deref(&self) -> &Self::Target {
+ &self.0
+ }
+ }
+
+ fn make_temp_dir_with_exes(prefix: &str, exe_names: &[&str]) -> TempDirGuard {
let dir = std::env::temp_dir().join(format!("patheditor_test_{}", prefix));
+ let _ = fs::remove_dir_all(&dir); // 清理残留
fs::create_dir_all(&dir).unwrap();
for name in exe_names {
fs::write(dir.join(name), b"fake").unwrap();
}
- dir
+ TempDirGuard(dir)
}
#[test]
diff --git a/docs/REMAINING-ISSUES.md b/docs/REMAINING-ISSUES.md
new file mode 100644
index 0000000..2409bdc
--- /dev/null
+++ b/docs/REMAINING-ISSUES.md
@@ -0,0 +1,35 @@
+# 未修复问题清单
+
+> 从 v5.1 全面代码审查中筛选,暂不修复,留待后续评估。
+
+---
+
+## 1. CLI main.rs 单体文件 (639 行)
+
+**严重级别**: LOW
+**文件**: `cli/src/main.rs`
+
+**问题**: 所有 18 条 CLI 命令集中在一个文件中。
+
+**建议**: 当前规模尚可维护,等到命令数超过 25 条或文件超过 1000 行时再拆分为 `commands/` 子模块。
+
+---
+
+## 2. GUI 命令层零测试
+
+**严重级别**: LOW
+**文件**: `gui/src/commands/*.rs` (8 个文件)
+
+**问题**: GUI 命令层是纯薄包装,无独立测试。
+
+**建议**: 不值得投入 — 命令正确性由编译器类型系统保证,运行期由 57 个 core 测试 + E2E 覆盖。
+
+---
+
+## 已修复(本批次)
+
+- ~~disabled.rs 测试写入真实文件~~ → `#[cfg(test)]` 条件编译重定向到 `std::env::temp_dir()`
+- ~~profiles.rs 同款问题~~ → 同上
+
+---
+*更新于: 2026-05-30 | 审查批次: v5.1 代码审查*
diff --git a/eslint.config.js b/eslint.config.js
index 73524c2..c560c7d 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -5,7 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
export default tseslint.config(
- { ignores: ['dist', 'gui'] },
+ { ignores: ['dist', 'gui', 'target', 'test-results', 'e2e', '*.config.*'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..1f56390
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,2 @@
+[toolchain]
+channel = "stable-x86_64-pc-windows-gnu"
diff --git a/src/components/path-list/PathTable.tsx b/src/components/path-list/PathTable.tsx
index ecb9936..74a0125 100644
--- a/src/components/path-list/PathTable.tsx
+++ b/src/components/path-list/PathTable.tsx
@@ -1,8 +1,9 @@
-import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
+import { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppStore } from '@/store/app-store';
-import { invoke } from '@tauri-apps/api/core';
import { TargetType } from '@/core/undo-redo';
+import { usePathValidation } from '@/hooks/use-path-validation';
+import type { ValidationState } from '@/hooks/use-path-validation';
interface PathTableProps {
tabId: 'system' | 'user';
@@ -14,9 +15,6 @@ interface PathRow {
enabled: boolean;
}
-type ValidationState = 'valid' | 'invalid' | 'unknown';
-const DEFAULT_VALIDATION_STATE: ValidationState = 'valid';
-
export function PathTable({ tabId }: PathTableProps) {
const { t } = useTranslation();
const sysPaths = useAppStore((s) => s.sysPaths);
@@ -29,42 +27,9 @@ export function PathTable({ tabId }: PathTableProps) {
const paths = tabId === 'system' ? sysPaths : userPaths;
const isActive = activeTab === tabId;
- // 本次会话中已验证过的路径缓存(key=path, value=ValidationState)
- const [validationCache, setValidationCache] = useState