重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复
- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
//! AI 工具注册表构建 + 文件路径校验
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_execute::shell::{execute, ShellRequest};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
|
||||
|
||||
@@ -32,6 +34,26 @@ fn validate_path(path: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
|
||||
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
|
||||
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
|
||||
fn truncate_output(s: &str, max: usize) -> (String, bool) {
|
||||
if s.len() <= max {
|
||||
return (s.to_string(), false);
|
||||
}
|
||||
let end = s.len();
|
||||
let mut start = end.saturating_sub(max);
|
||||
// 推进到 char 边界,避免从多字节 UTF-8 中间切开
|
||||
while start < end && !s.is_char_boundary(start) {
|
||||
start += 1;
|
||||
}
|
||||
let tail = &s[start..];
|
||||
(
|
||||
format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -88,17 +110,11 @@ async fn bind_dir_to_project(
|
||||
if !dir.is_dir() {
|
||||
anyhow::bail!("目录不存在: {path}");
|
||||
}
|
||||
// 防重复:normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)
|
||||
// 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict
|
||||
// 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。
|
||||
let target = df_project::scan::normalize_path(path);
|
||||
let projects = repo.list_active().await?;
|
||||
for proj in &projects {
|
||||
if proj.id != id {
|
||||
if let Some(pp) = &proj.path {
|
||||
if df_project::scan::normalize_path(pp) == target {
|
||||
anyhow::bail!("目录已被项目「{}」绑定", proj.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? {
|
||||
anyhow::bail!("目录已被项目「{}」绑定", conflict.name);
|
||||
}
|
||||
// stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime)
|
||||
let stack: Vec<String> = if let Some(s) = stack_opt {
|
||||
@@ -388,6 +404,55 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
|
||||
})),
|
||||
);
|
||||
registry.register(
|
||||
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
|
||||
df_ai::ai_tools::object_schema(vec![
|
||||
("command", "string", true),
|
||||
("working_dir", "string", false),
|
||||
("timeout_secs", "integer", false),
|
||||
]),
|
||||
RiskLevel::High,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let command = args["command"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
||||
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
|
||||
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
|
||||
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
|
||||
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
|
||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||
Some(d) => {
|
||||
validate_path(d)?;
|
||||
d.to_string()
|
||||
}
|
||||
None => workspace_root().to_string_lossy().to_string(),
|
||||
};
|
||||
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖
|
||||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60);
|
||||
|
||||
let request = ShellRequest {
|
||||
command: command.to_string(),
|
||||
working_dir: Some(working_dir.clone()),
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(timeout_secs),
|
||||
};
|
||||
let result = execute(request).await?;
|
||||
|
||||
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
|
||||
const MAX_OUT: usize = 10_000;
|
||||
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
|
||||
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"command": command,
|
||||
"working_dir": working_dir,
|
||||
"exit_code": result.exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"truncated": stdout_trunc || stderr_trunc,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
// ── 文件系统 ──
|
||||
registry.register(
|
||||
@@ -409,13 +474,23 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
if metadata.len() > 1_048_576 {
|
||||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||||
}
|
||||
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content).await
|
||||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||||
if let Err(e) = file.read_to_string(&mut content).await {
|
||||
if e.kind() == std::io::ErrorKind::InvalidData {
|
||||
return Ok(serde_json::json!({
|
||||
"path": path, "content": null, "binary": true,
|
||||
"size": metadata.len(),
|
||||
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
|
||||
}));
|
||||
}
|
||||
anyhow::bail!("读取文件失败: {}", e);
|
||||
}
|
||||
// limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
|
||||
let result = if let Some(offset) = args["offset"].as_u64() {
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let skip = offset as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(200) as usize;
|
||||
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
|
||||
lines.into_iter().skip(skip).take(limit).collect::<Vec<&str>>().join("\n")
|
||||
} else {
|
||||
content.clone()
|
||||
@@ -426,7 +501,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
);
|
||||
registry.register(
|
||||
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false)]),
|
||||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
|
||||
RiskLevel::Low,
|
||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||
let resolved = resolve_workspace_path(
|
||||
@@ -435,8 +510,9 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||||
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
|
||||
let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize;
|
||||
let mut entries = Vec::new();
|
||||
let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?;
|
||||
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
|
||||
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
|
||||
})),
|
||||
);
|
||||
@@ -454,13 +530,51 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
if content.len() > 1_048_576 {
|
||||
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
|
||||
}
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let target = std::path::Path::new(path);
|
||||
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
|
||||
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
|
||||
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
|
||||
Ok(m) if m.len() > 0 => {
|
||||
let bak = format!("{}.bak", path);
|
||||
tokio::fs::copy(path, &bak).await
|
||||
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
|
||||
Some(m.len())
|
||||
}
|
||||
Ok(_) => Some(0), // 空文件(无需备份)
|
||||
Err(_) => None, // 不存在(新建)
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||||
}
|
||||
tokio::fs::write(path, content).await
|
||||
.map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?;
|
||||
Ok(serde_json::json!({ "path": path, "bytes_written": content.len() }))
|
||||
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
|
||||
let tmp = format!("{}.tmp-write", path);
|
||||
if let Err(e) = tokio::fs::write(&tmp, content).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
|
||||
}
|
||||
if let Err(e) = tokio::fs::rename(&tmp, path).await {
|
||||
let _ = tokio::fs::remove_file(&tmp).await;
|
||||
return Err(anyhow::anyhow!("原子替换失败: {}", e));
|
||||
}
|
||||
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
|
||||
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
|
||||
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
|
||||
if old_size.map(|s| s > 0).unwrap_or(false) {
|
||||
let bak = format!("{}.bak", path);
|
||||
let _ = tokio::fs::remove_file(&bak).await;
|
||||
}
|
||||
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
|
||||
if let Some(old) = old_size {
|
||||
if old > 0 && (content.len() as f64 / old as f64) < 0.1 {
|
||||
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len());
|
||||
}
|
||||
}
|
||||
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size }))
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -469,7 +583,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||||
|
||||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||||
///
|
||||
/// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部
|
||||
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
|
||||
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
|
||||
fn list_dir_recursive<'a>(
|
||||
path: &'a str,
|
||||
@@ -488,15 +602,27 @@ fn list_dir_recursive<'a>(
|
||||
return Ok(true); // 达上限截断
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let metadata = entry.metadata().await?;
|
||||
let is_dir = metadata.is_dir();
|
||||
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
|
||||
if skip_noise && is_noise_dir(&name) {
|
||||
continue;
|
||||
}
|
||||
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
|
||||
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。
|
||||
if skip_noise && is_noise_file(&name) {
|
||||
continue;
|
||||
}
|
||||
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
|
||||
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
|
||||
let file_type = entry.file_type().await?;
|
||||
let is_symlink = file_type.is_symlink();
|
||||
let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归
|
||||
result.push(serde_json::json!({
|
||||
"name": name,
|
||||
"type": if is_dir { "directory" } else { "file" },
|
||||
"size": metadata.len(),
|
||||
"type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" },
|
||||
"size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) },
|
||||
"depth": depth,
|
||||
}));
|
||||
// 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等)
|
||||
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
|
||||
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
|
||||
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
|
||||
@@ -517,3 +643,178 @@ fn is_noise_dir(name: &str) -> bool {
|
||||
];
|
||||
NOISE_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
|
||||
///
|
||||
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
|
||||
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
|
||||
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
|
||||
fn is_noise_file(name: &str) -> bool {
|
||||
const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
|
||||
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||||
#[test]
|
||||
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
|
||||
// 噪音目录命中
|
||||
assert!(is_noise_dir(".git"));
|
||||
assert!(is_noise_dir("node_modules"));
|
||||
assert!(is_noise_dir("target"));
|
||||
assert!(is_noise_dir("dist"));
|
||||
assert!(is_noise_dir("build"));
|
||||
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
|
||||
assert!(!is_noise_dir(".gitignore"));
|
||||
assert!(!is_noise_dir(".gitattributes"));
|
||||
assert!(!is_noise_dir(".gitkeep"));
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:普通目录不命中
|
||||
#[test]
|
||||
fn test_is_noise_dir_normal_dirs_not_matched() {
|
||||
assert!(!is_noise_dir("src"));
|
||||
assert!(!is_noise_dir("tests"));
|
||||
assert!(!is_noise_dir("docs"));
|
||||
assert!(!is_noise_dir("main.rs"));
|
||||
assert!(!is_noise_dir(""));
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
|
||||
#[test]
|
||||
fn test_is_noise_dir_case_sensitive() {
|
||||
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
|
||||
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
|
||||
assert!(!is_noise_dir("Target"));
|
||||
assert!(!is_noise_dir("DIST"));
|
||||
assert!(!is_noise_dir("NODE_MODULES"));
|
||||
}
|
||||
|
||||
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
|
||||
#[test]
|
||||
fn test_is_noise_file_matches_temp_artifacts() {
|
||||
// write_file 原子写副产物
|
||||
assert!(is_noise_file("PROGRESS.md.bak"));
|
||||
assert!(is_noise_file("PROGRESS.md.tmp-write"));
|
||||
// 编辑器临时文件
|
||||
assert!(is_noise_file(".main.rs.swp"));
|
||||
assert!(is_noise_file("main.rs~"));
|
||||
// 路径含但非后缀的应不命中(避免误杀)
|
||||
assert!(!is_noise_file("backup.bak.md"));
|
||||
assert!(!is_noise_file("main.rs"));
|
||||
assert!(!is_noise_file(".gitignore"));
|
||||
assert!(!is_noise_file(""));
|
||||
}
|
||||
|
||||
/// 造一个临时目录树用于 list_dir_recursive 测试
|
||||
fn build_noise_tree(root: &Path) {
|
||||
// .git/HEAD (噪音目录,内部文件不应出现)
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap();
|
||||
// node_modules/x/index.js (噪音目录,内部文件不应出现)
|
||||
fs::create_dir_all(root.join("node_modules").join("x")).unwrap();
|
||||
fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap();
|
||||
// target/debug/bin (噪音目录,内部文件不应出现)
|
||||
fs::create_dir_all(root.join("target").join("debug")).unwrap();
|
||||
fs::write(root.join("target").join("debug").join("app"), "binary").unwrap();
|
||||
// 普通 src/main.rs (应出现)
|
||||
fs::create_dir_all(root.join("src")).unwrap();
|
||||
fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap();
|
||||
// .gitignore 文件 (文件名不是噪音目录,应出现)
|
||||
fs::write(root.join(".gitignore"), "/target\n").unwrap();
|
||||
}
|
||||
|
||||
/// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部)
|
||||
#[tokio::test]
|
||||
async fn test_list_dir_recursive_filters_noise_dirs() {
|
||||
let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
build_noise_tree(&tmp);
|
||||
let root = tmp.to_string_lossy().to_string();
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
|
||||
assert!(!truncated);
|
||||
|
||||
let names: Vec<String> = entries.iter()
|
||||
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
// 噪音目录本身及其内部文件均不应出现
|
||||
assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回");
|
||||
assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回");
|
||||
assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回");
|
||||
// 噪音目录内部文件也不应被递归带入
|
||||
assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现");
|
||||
assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现");
|
||||
assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现");
|
||||
|
||||
// 普通目录与 .gitignore 文件应正常返回
|
||||
assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回");
|
||||
assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回");
|
||||
assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回");
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// list_dir_recursive:skip_noise=false 时噪音目录正常返回
|
||||
#[tokio::test]
|
||||
async fn test_list_dir_recursive_keeps_noise_when_disabled() {
|
||||
let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
build_noise_tree(&tmp);
|
||||
let root = tmp.to_string_lossy().to_string();
|
||||
|
||||
let mut entries = Vec::new();
|
||||
list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap();
|
||||
|
||||
let names: Vec<String> = entries.iter()
|
||||
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
// 关闭过滤后噪音目录应作为 entry 出现
|
||||
assert!(names.contains(&".git".to_string()));
|
||||
assert!(names.contains(&"node_modules".to_string()));
|
||||
assert!(names.contains(&"target".to_string()));
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// list_dir_recursive:max_depth 控制递归深度
|
||||
#[tokio::test]
|
||||
async fn test_list_dir_recursive_max_depth() {
|
||||
// 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3)
|
||||
let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap();
|
||||
fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap();
|
||||
let root = tmp.to_string_lossy().to_string();
|
||||
|
||||
// max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1)
|
||||
let mut entries = Vec::new();
|
||||
list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap();
|
||||
let names: Vec<String> = entries.iter()
|
||||
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
assert!(names.contains(&"a".to_string()), "depth1 应含 a");
|
||||
assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)");
|
||||
assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)");
|
||||
assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs");
|
||||
|
||||
// max_depth=3: 应能到 depth3 的 main.rs
|
||||
let mut entries = Vec::new();
|
||||
list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
|
||||
let names: Vec<String> = entries.iter()
|
||||
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs");
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user