根因: 批次1 workflow(2196c77)做 @/ 路径统一时对 AiChat.vue/Ideas.vue 做了整文件替换(非手术式编辑),把6254d06之后积累的功能改动全部抹掉。 AiChat.vue 恢复 12 项功能: - row-reverse 用户头像右侧布局 - groupedActive 对话按今天/昨天/更早分组 - archivedConvs 归档分组(可折叠) - confirmClearChat 清空对话二次确认 - showBackToBottom 回到底部按钮(含 smooth 参数) - queue UI 生成中消息排队可视化 - 技能联想(/ 触发浮层) - token-usage 展示 - .ai-conv-group/item-actions/back-to-bottom/queue/skill 样式组 - onBeforeUnmount 补 stopListener() (CR-24 回归修复) Ideas.vue 恢复 i18n/useConfirm/useRendered/过滤器/晋升逻辑 新增: - F-260615-03 list工具 truncated 截断标志(tool_registry.rs) - AR-8-scroll scrollToBottom 加 smooth 参数支持平滑滚动 vue-tsc 0 err / cargo check 0 err
948 lines
49 KiB
Rust
948 lines
49 KiB
Rust
//! 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};
|
||
|
||
use df_core::types::new_id;
|
||
|
||
use crate::commands::now_millis;
|
||
|
||
/// 验证文件路径:禁止访问系统敏感目录
|
||
fn validate_path(path: &str) -> anyhow::Result<()> {
|
||
// 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃)
|
||
let normalized = path.replace('/', "\\");
|
||
let lower = normalized.to_lowercase();
|
||
if lower.contains("..") {
|
||
anyhow::bail!("禁止路径遍历 (..)");
|
||
}
|
||
if lower.contains("\\.ssh")
|
||
|| lower.contains("\\.aws")
|
||
|| lower.contains("\\.gnupg")
|
||
|| lower.contains("\\appdata\\")
|
||
|| lower.contains("\\programdata\\")
|
||
|| lower.contains("\\windows\\")
|
||
|| lower.contains("\\system32\\")
|
||
{
|
||
anyhow::bail!("禁止访问敏感系统目录");
|
||
}
|
||
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"))
|
||
.parent()
|
||
.and_then(|p| p.parent())
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| PathBuf::from("."))
|
||
}
|
||
|
||
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
|
||
///
|
||
/// 双层校验:
|
||
/// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界
|
||
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
|
||
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
|
||
fn resolve_workspace_path(path: &str) -> anyhow::Result<PathBuf> {
|
||
validate_path(path)?;
|
||
let root = workspace_root();
|
||
let resolved = if Path::new(path).is_absolute() {
|
||
PathBuf::from(path)
|
||
} else {
|
||
root.join(path)
|
||
};
|
||
// 词法层:防明显越界(不存在路径的兜底)
|
||
if !resolved.starts_with(&root) {
|
||
anyhow::bail!("禁止访问项目目录之外: {}", path);
|
||
}
|
||
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace
|
||
if resolved.exists() {
|
||
let canon_root = root.canonicalize()?;
|
||
let canon_resolved = resolved.canonicalize()?;
|
||
if !canon_resolved.starts_with(&canon_root) {
|
||
anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path);
|
||
}
|
||
}
|
||
Ok(resolved)
|
||
}
|
||
|
||
/// 为项目绑定代码目录的公共逻辑(FR-D7 抽取:create_project 与 bind_directory 共用,消除逐行重复)
|
||
///
|
||
/// 步骤:① 目录存在性校验 ② normalize_path 比较防重复绑定(跨其他项目)
|
||
/// ③ stack 由调用方提供则用,否则 spawn_blocking 探测(detect_stack 内含多次同步 fs IO)
|
||
/// ④ 写回 path / stack 字段
|
||
///
|
||
/// - `stack_opt`: 调用方解析好的逗号分隔技术栈原始串(None=让本函数自动探测)
|
||
/// - 返回最终采用的 stack(供调用方回填响应,保持原 bind_directory 返回结构)
|
||
async fn bind_dir_to_project(
|
||
repo: &df_storage::crud::ProjectRepo,
|
||
id: &str,
|
||
path: &str,
|
||
stack_opt: Option<&str>,
|
||
) -> anyhow::Result<Vec<String>> {
|
||
let dir = std::path::Path::new(path);
|
||
if !dir.is_dir() {
|
||
anyhow::bail!("目录不存在: {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);
|
||
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 {
|
||
s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect()
|
||
} else {
|
||
let dir_buf = std::path::PathBuf::from(path);
|
||
tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf))
|
||
.await
|
||
.map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))??
|
||
};
|
||
let stack_json = serde_json::to_string(&stack)?;
|
||
repo.update_field(id, "path", path).await?;
|
||
repo.update_field(id, "stack", &stack_json).await?;
|
||
Ok(stack)
|
||
}
|
||
|
||
/// 构建 AI 工具注册表 — handler 即唯一执行路径(schema+risk+实现同源,消除双轨)
|
||
///
|
||
/// CRUD 工具闭包捕获 `db` Arc 重建 Repo;文件系统工具复用 resolve_workspace_path /
|
||
/// list_dir_recursive。新增工具只改这里一处,定义与实现同源,编译期保证一致。
|
||
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
|
||
let mut registry = AiToolRegistry::new();
|
||
|
||
// ── 只读 (Low) ──
|
||
registry.register(
|
||
"list_projects", "列出所有项目,返回项目列表(ID、名称、状态、描述)。最多返回 50 条",
|
||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
|
||
let total = items.len();
|
||
let truncated = total > 50;
|
||
items.truncate(50); // 防 LLM context 膨胀
|
||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"list_tasks", "列出任务,可按 project_id 筛选。最多返回 50 条",
|
||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
|
||
repo.query("project_id", pid).await?
|
||
} else {
|
||
repo.list_all().await?
|
||
};
|
||
let total = tasks.len();
|
||
let truncated = total > 50;
|
||
tasks.truncate(50); // 防 LLM context 膨胀
|
||
Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"list_ideas", "列出所有灵感。最多返回 50 条",
|
||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||
let mut items = repo.list_all().await?;
|
||
let total = items.len();
|
||
let truncated = total > 50;
|
||
items.truncate(50); // 防 LLM context 膨胀
|
||
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 创建 (Medium) ──
|
||
registry.register(
|
||
"update_project", "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||
RiskLevel::Medium,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
|
||
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
|
||
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
|
||
if !df_storage::crud::is_allowed_column("projects", field) {
|
||
anyhow::bail!("不允许更新字段 '{}'", field);
|
||
}
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
repo.update_field(id, field, value).await?;
|
||
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"create_project", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
|
||
df_ai::ai_tools::object_schema(vec![
|
||
("name", "string", true), ("description", "string", false),
|
||
("path", "string", false), ("stack", "string", false),
|
||
]),
|
||
RiskLevel::Medium,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
|
||
let description = args["description"].as_str().unwrap_or("");
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let record = ProjectRecord {
|
||
id: new_id(), name: name.to_string(), description: description.to_string(),
|
||
status: "planning".to_string(), idea_id: None,
|
||
path: None, stack: None,
|
||
created_at: now_millis(), updated_at: now_millis(),
|
||
};
|
||
let id = record.id.clone();
|
||
repo.insert(record).await?;
|
||
// path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源)
|
||
let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||
if let Some(path) = path_opt {
|
||
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||
let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?;
|
||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
|
||
} else {
|
||
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
|
||
}
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
|
||
RiskLevel::Medium,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
|
||
let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
|
||
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
|
||
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"create_task", "在指定项目下创建新任务",
|
||
df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false)]),
|
||
RiskLevel::Medium,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
|
||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||
let record = TaskRecord {
|
||
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
|
||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
|
||
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
|
||
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
|
||
created_at: now_millis(), updated_at: now_millis(),
|
||
};
|
||
let id = record.id.clone();
|
||
repo.insert(record).await?;
|
||
Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"update_task", "更新任务的指定字段(title/description/status/priority/assignee 等),需要提供任务 ID、字段名和新值",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
|
||
RiskLevel::Medium,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
|
||
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
|
||
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
|
||
if !df_storage::crud::is_allowed_column("tasks", field) {
|
||
anyhow::bail!("不允许更新字段 '{}'", field);
|
||
}
|
||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||
repo.update_field(id, field, value).await?;
|
||
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
|
||
})
|
||
})},
|
||
);
|
||
// FR-D6:delete_task 对齐 commands::task::delete_task —— tasks 表无 deleted_at,走硬删
|
||
// (与 delete_project 的软删不同:projects 有专门回收站机制,tasks 没有,只能硬删)
|
||
registry.register(
|
||
"delete_task", "删除任务(硬删,不可恢复。清理孤儿任务时务必用本工具,不要误用 delete_project)",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||
let deleted = repo.delete(id).await?;
|
||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"create_idea", "捕获一个新灵感",
|
||
df_ai::ai_tools::object_schema(vec![
|
||
("title", "string", true), ("description", "string", false),
|
||
("tags", "string", false), ("source", "string", false),
|
||
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
|
||
("priority", "integer", false),
|
||
]),
|
||
RiskLevel::Medium,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||
let repo = df_storage::crud::IdeaRepo::new(&db);
|
||
let record = IdeaRecord {
|
||
id: new_id(), title: title.to_string(),
|
||
description: args["description"].as_str().unwrap_or("").to_string(),
|
||
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
|
||
status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32,
|
||
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
|
||
source: args["source"].as_str().map(|s| s.to_string()),
|
||
promoted_to: None, ai_analysis: None, scores: None,
|
||
created_at: now_millis(), updated_at: now_millis(),
|
||
};
|
||
let id = record.id.clone();
|
||
repo.insert(record).await?;
|
||
Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 高风险 (High) ──
|
||
registry.register(
|
||
"delete_project", "删除项目(移入回收站,可恢复。永久删除用 purge_project)",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let deleted = repo.soft_delete(id).await?;
|
||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"restore_project", "从回收站恢复已删除项目",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let restored = repo.restore(id).await?;
|
||
Ok(serde_json::json!({ "restored": restored, "id": id }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"purge_project", "永久删除项目及关联数据(不可恢复)",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
|
||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let purged = repo.purge_with_descendants(id).await?;
|
||
Ok(serde_json::json!({ "purged": purged, "id": id }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"list_trash", "列出回收站已删除项目",
|
||
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
|
||
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
|
||
let db = db.clone();
|
||
Box::pin(async move {
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
let mut items = repo.list_deleted().await?;
|
||
items.truncate(50); // 防 LLM context 膨胀
|
||
Ok(serde_json::to_value(items)?)
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"run_workflow", "运行指定的工作流 DAG",
|
||
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High,
|
||
Box::new(|_args: serde_json::Value| Box::pin(async move {
|
||
// run_workflow 需完整 DAG 执行,返回提示由前端触发
|
||
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(
|
||
"read_file", "读取文件内容,返回文本内容。支持 offset 和 limit 参数分页读取大文件",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false)]),
|
||
RiskLevel::Low,
|
||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||
let resolved = resolve_workspace_path(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
|
||
use tokio::fs::File;
|
||
use tokio::io::AsyncReadExt;
|
||
let mut file = File::open(path).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||
let metadata = file.metadata().await
|
||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||
if metadata.len() > 1_048_576 {
|
||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||
}
|
||
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
|
||
let mut content = String::new();
|
||
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).min(2000) as usize;
|
||
lines.into_iter().skip(skip).take(limit).collect::<Vec<&str>>().join("\n")
|
||
} else {
|
||
content.clone()
|
||
};
|
||
let line_count = content.lines().count();
|
||
Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "lines": line_count }))
|
||
})),
|
||
);
|
||
registry.register(
|
||
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
|
||
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(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
)?;
|
||
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, max_depth, 1000, skip_noise, &mut entries).await?;
|
||
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
|
||
})),
|
||
);
|
||
registry.register(
|
||
"write_file", "写入或创建文件,自动创建不存在的父目录",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||
RiskLevel::Medium,
|
||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||
let resolved = resolve_workspace_path(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
|
||
if content.len() > 1_048_576 {
|
||
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
|
||
}
|
||
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))?;
|
||
}
|
||
// 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 }))
|
||
})),
|
||
);
|
||
|
||
// ── 文件元信息 (Low risk) ──
|
||
registry.register(
|
||
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
|
||
RiskLevel::Low,
|
||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||
let resolved = resolve_workspace_path(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let p = std::path::Path::new(path);
|
||
if !p.exists() {
|
||
return Ok(serde_json::json!({ "path": path, "exists": false }));
|
||
}
|
||
let metadata = tokio::fs::metadata(path).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||
let is_dir = metadata.is_dir();
|
||
let size = metadata.len();
|
||
let modified = metadata.modified()
|
||
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||
.map(|d| d.as_millis() as i64);
|
||
// is_binary: 读前 8KB 检测 \x00
|
||
let is_binary = if !is_dir && size > 0 {
|
||
let sample = tokio::fs::read(path).await.unwrap_or_default();
|
||
sample[..sample.len().min(8192)].contains(&0x00)
|
||
} else { false };
|
||
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
|
||
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
|
||
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
|
||
} else { None };
|
||
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
|
||
})),
|
||
);
|
||
|
||
// ── 追加写入 (Medium risk) ──
|
||
registry.register(
|
||
"append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||
RiskLevel::Medium,
|
||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||
let resolved = resolve_workspace_path(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||
if !parent.starts_with(&workspace_root()) {
|
||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||
}
|
||
tokio::fs::create_dir_all(parent).await
|
||
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||
}
|
||
use tokio::io::AsyncWriteExt;
|
||
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
|
||
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
|
||
let bytes = content.as_bytes();
|
||
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
|
||
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
|
||
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
|
||
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
|
||
})),
|
||
);
|
||
|
||
// ── 文件搜索 (Low risk) ──
|
||
registry.register(
|
||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false)]),
|
||
RiskLevel::Low,
|
||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||
let resolved = resolve_workspace_path(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||
let pattern_lower = pattern.to_lowercase();
|
||
const MAX_RESULTS: usize = 50;
|
||
let mut results = Vec::new();
|
||
let mut total = 0u64;
|
||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?;
|
||
let has_more = total as usize > MAX_RESULTS;
|
||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more }))
|
||
})),
|
||
);
|
||
|
||
registry
|
||
}
|
||
|
||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||
///
|
||
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
|
||
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
|
||
fn list_dir_recursive<'a>(
|
||
path: &'a str,
|
||
recursive: bool,
|
||
depth: usize,
|
||
max_depth: usize,
|
||
max_entries: usize,
|
||
skip_noise: bool,
|
||
result: &'a mut Vec<serde_json::Value>,
|
||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + Send + 'a>> {
|
||
Box::pin(async move {
|
||
let mut dir = tokio::fs::read_dir(path).await
|
||
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
|
||
while let Some(entry) = dir.next_entry().await? {
|
||
if result.len() >= max_entries {
|
||
return Ok(true); // 达上限截断
|
||
}
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
// 噪音目录(.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_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,
|
||
}));
|
||
// 仅递归真实目录(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?;
|
||
if truncated {
|
||
return Ok(true);
|
||
}
|
||
}
|
||
}
|
||
Ok(false)
|
||
})
|
||
}
|
||
|
||
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
|
||
fn is_noise_dir(name: &str) -> bool {
|
||
const NOISE_DIRS: &[&str] = &[
|
||
".git", "node_modules", "target", "dist", "build",
|
||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||
];
|
||
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))
|
||
}
|
||
|
||
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
|
||
fn search_files_recursive<'a>(
|
||
path: &'a str,
|
||
pattern: &'a str,
|
||
recursive: bool,
|
||
depth: usize,
|
||
max_depth: usize,
|
||
max_results: usize,
|
||
results: &'a mut Vec<serde_json::Value>,
|
||
total: &'a mut u64,
|
||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
|
||
Box::pin(async move {
|
||
let mut dir = tokio::fs::read_dir(path).await
|
||
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
|
||
while let Some(entry) = dir.next_entry().await? {
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
let metadata = entry.metadata().await?;
|
||
let is_dir = metadata.is_dir();
|
||
if !is_dir {
|
||
// 字符串包含匹配(大小写不敏感)
|
||
if name.to_lowercase().contains(pattern) {
|
||
*total += 1;
|
||
if results.len() < max_results {
|
||
let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||
results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
|
||
}
|
||
}
|
||
} else if recursive && depth < max_depth {
|
||
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||
search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?;
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
#[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();
|
||
}
|
||
}
|