tree-sitter 语法层符号解析,治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。 核心思想:信息密度≠压缩,read_symbol 按语义层级返符号骨架/全文,非物理读全文件。 - code_intel.rs: grammar_for 集中 lookup(Rust/TS/JS/Vue借TS,3 grammar 4 类)+ read_symbol 三态(骨架默认/全文)+ 不报错兜底(无grammar/解析失败/未找到→grep提示) - tool_registry 注册 read_symbol(对齐 read_file handler 闭包模式) - Cargo.toml 加 tree-sitter 0.25 + rust/typescript/javascript grammar - AST 设计文档 grammar 策略章节定稿(静态编译+lookup,不动态不trait) 实测: code_intel.rs 全文 21851B vs read_symbol 骨架 897B,降 24.4x(达设计目标一个量级+)。单测 14 全过。
3179 lines
174 KiB
Rust
3179 lines
174 KiB
Rust
//! AI 工具注册表构建 + 文件路径校验
|
||
|
||
use std::collections::HashMap;
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::{Arc, LazyLock};
|
||
|
||
use tokio::sync::{Mutex as TokioMutex, RwLock};
|
||
|
||
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_types::types::new_id;
|
||
|
||
use crate::commands::now_millis;
|
||
use crate::state::AllowedDirs;
|
||
|
||
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
|
||
/// 用于 list_projects / list_tasks / list_ideas / list_trash
|
||
const MAX_LIST_RESULTS: usize = 50;
|
||
|
||
/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。
|
||
/// 提取为常量便于在超时标注处引用同一来源(F-260616-04)。
|
||
const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
|
||
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
|
||
const MAX_RUN_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||
|
||
/// 生成行级 unified diff(无外部依赖,基于 LCS)。
|
||
/// 仅标 +/- 前缀,不做 hunk header(足够审批卡/审计留痕可读)。
|
||
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
|
||
///
|
||
/// AE-2025-03(路径 B):audit.rs 挂起审批前预读旧文件复用此函数生成 diff,
|
||
/// 供前端审批卡即时预览(write_file 审批不再只看裸 content)。故 pub(crate)。
|
||
///
|
||
/// BUG-260617-07: LCS DP 表 O(n*m) 内存,两文件均>1000 行时 DP 表可达数百 MB
|
||
/// (5000×5000×8B≈200MB)。虽然 1MB 字节上限能挡住多数情况,但短行高密度的源码
|
||
/// (大量空行/单字符行)仍可能突破。改:两文件均>1000 行时跳过 LCS,退化为
|
||
/// 「删旧全量 + 增新全量」朴素行对比 + 截断提示。朴素对比 O(n+m) 内存,安全。
|
||
/// (patch_file 调用方传入的 old/new 是局部替换,通常远小于全文,正常路径仍走 LCS)
|
||
pub(crate) fn generate_diff(old: &str, new: &str) -> String {
|
||
let a: Vec<&str> = old.lines().collect();
|
||
let b: Vec<&str> = new.lines().collect();
|
||
let (n, m) = (a.len(), b.len());
|
||
|
||
// 超长输入降级:两文件均>1000 行跳过 LCS DP(防 200MB+ 内存峰值),
|
||
// 退化为朴素全删全增 diff(配 300 行截断),足够审批卡看出"大范围改动"语义。
|
||
const LCS_MAX_LINES: usize = 1000;
|
||
if n > LCS_MAX_LINES && m > LCS_MAX_LINES {
|
||
let mut out = String::new();
|
||
let mut changes = 0usize;
|
||
for line in &a { out.push_str("-"); out.push_str(line); out.push('\n'); changes += 1; }
|
||
for line in &b { out.push_str("+"); out.push_str(line); out.push('\n'); changes += 1; }
|
||
if changes > 300 {
|
||
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
|
||
return format!(
|
||
"{}\n... (输入过长({}/{} 行)已跳过 LCS 退化对比,diff 已截断,共 {} 处变更行)",
|
||
kept, n, m, changes
|
||
);
|
||
}
|
||
return out.trim_end_matches('\n').to_string() + "\n";
|
||
}
|
||
|
||
// LCS 动态规划表(usize 即可;已由 LCS_MAX_LINES 挡住超大输入,行数有限)
|
||
let mut dp = vec![vec![0usize; m + 1]; n + 1];
|
||
for i in (0..n).rev() {
|
||
for j in (0..m).rev() {
|
||
dp[i][j] = if a[i] == b[j] {
|
||
dp[i + 1][j + 1] + 1
|
||
} else {
|
||
dp[i + 1][j].max(dp[i][j + 1])
|
||
};
|
||
}
|
||
}
|
||
|
||
// 回溯:相同行原样输出(空格前缀),旧独有加 '-',新独有加 '+'
|
||
let mut out = String::new();
|
||
let (mut i, mut j) = (0usize, 0usize);
|
||
let mut changes = 0usize;
|
||
while i < n && j < m {
|
||
if a[i] == b[j] {
|
||
out.push_str(" "); out.push_str(a[i]); out.push('\n');
|
||
i += 1; j += 1;
|
||
} else if dp[i + 1][j] >= dp[i][j + 1] {
|
||
out.push_str("-"); out.push_str(a[i]); out.push('\n');
|
||
i += 1; changes += 1;
|
||
} else {
|
||
out.push_str("+"); out.push_str(b[j]); out.push('\n');
|
||
j += 1; changes += 1;
|
||
}
|
||
}
|
||
while i < n { out.push_str("-"); out.push_str(a[i]); out.push('\n'); i += 1; changes += 1; }
|
||
while j < m { out.push_str("+"); out.push_str(b[j]); out.push('\n'); j += 1; changes += 1; }
|
||
|
||
// 极端情况:差异行过多(>300),截断防 LLM context 爆炸
|
||
if changes > 300 {
|
||
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
|
||
return format!("{}\n... (diff 已截断,共 {} 处变更行)", kept, changes);
|
||
}
|
||
// 末尾多余换行清理(保持单尾换行)
|
||
out.trim_end_matches('\n').to_string() + "\n"
|
||
}
|
||
|
||
/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过
|
||
///
|
||
/// 安全要点(BUG-260617-03):
|
||
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
|
||
/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
|
||
/// 解码后再走分段归一化校验。
|
||
/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤
|
||
/// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别
|
||
/// 解码后 `..%2f` / `..\` 等变体。
|
||
/// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。
|
||
fn validate_path(path: &str) -> anyhow::Result<()> {
|
||
// 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串)
|
||
// percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。
|
||
use percent_encoding::percent_decode_str;
|
||
let decoded = percent_decode_str(path).decode_utf8_lossy().into_owned();
|
||
|
||
// 2) 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃)
|
||
let normalized = decoded.replace('/', "\\");
|
||
let lower = normalized.to_lowercase();
|
||
|
||
// 3) 分段归一化检查:按分隔符切分,任何一段 == ".." 视为路径遍历。
|
||
// 比纯 contains("..") 更精准(不误伤 my..file 这类合法名),且能识别解码后的 `..`。
|
||
let has_traversal = lower
|
||
.split(|c| c == '\\' || c == '/')
|
||
.any(|seg| seg == "..");
|
||
if has_traversal {
|
||
anyhow::bail!("禁止路径遍历 (..)");
|
||
}
|
||
|
||
// 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心):
|
||
// 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup,
|
||
// 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致)
|
||
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
|
||
anyhow::bail!("禁止访问敏感系统目录");
|
||
}
|
||
// F-260620: AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
|
||
// AppData 走白名单流程(is_authorized/check_path_authorization):默认不在白名单 → 弹 DirAuthDialog
|
||
// → 用户显式授权(once/always)→ 放行。真正敏感凭据(.ssh/.aws/.gnupg)由 is_in_system_blacklist
|
||
// 硬拦(授权也不放,防凭据泄漏),AppData 内应用数据(DBeaver Scripts/项目配置等)归用户自治——
|
||
// 用户授权 = 用户同意,AI 不越权也不一刀切挡用户数据。
|
||
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,
|
||
)
|
||
}
|
||
|
||
// ============================================================
|
||
// patch_file 三模式共用底层(F-260617-01)
|
||
//
|
||
// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
|
||
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
|
||
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
|
||
// 提取为纯函数便于直接单测(不依赖 async/fs/锁),handler 闭包内复用。
|
||
// ============================================================
|
||
|
||
/// 按行号区间替换:将 content 第 [start, end] 行(1-based, 含首尾)替换为 new_text。
|
||
///
|
||
/// - 与 search_files 一致用 `\n` 切分;末尾尾随 `\n` 保留(避免误删文件末换行)。
|
||
/// - 行号 1-based,含首尾:replace_lines(2,3) 替换第 2、3 两行。
|
||
/// - 越界(start<1 或 end>lines.len() 或 start>end)返 Err。
|
||
/// - new_text 可为多行(含 `\n`),整体作为替换块原样插入。
|
||
pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text: &str) -> anyhow::Result<String> {
|
||
if start < 1 {
|
||
anyhow::bail!("start 行号必须 >= 1(got {})", start);
|
||
}
|
||
if start > end {
|
||
anyhow::bail!("start({}) > end({})", start, end);
|
||
}
|
||
// 末尾尾随换行的文件:lines() 会丢掉末尾空段,用 split 保留语义更稳,
|
||
// 但为与 search_files/read_file 行计数一致,这里用 lines() 并单独处理尾换行。
|
||
let trailing_newline = content.ends_with('\n');
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
if end > lines.len() {
|
||
anyhow::bail!("end 行号 {} 超出文件总行数 {}(1-based 含尾)", end, lines.len());
|
||
}
|
||
// splice: 前 start-1 行 + new_text + 第 end 行之后
|
||
let mut out = String::new();
|
||
for (i, line) in lines.iter().enumerate() {
|
||
let lineno = i + 1;
|
||
if lineno < start {
|
||
out.push_str(line);
|
||
out.push('\n');
|
||
} else if lineno == start {
|
||
out.push_str(new_text);
|
||
// 若 new_text 未以换行结尾且其后还有保留行,需补一个换行分隔
|
||
if !new_text.ends_with('\n') {
|
||
out.push('\n');
|
||
}
|
||
} else if lineno > end {
|
||
out.push_str(line);
|
||
out.push('\n');
|
||
}
|
||
// start < lineno <= end 的行被丢弃(落入区间内)
|
||
}
|
||
// 还原尾换行语义:原文件以 \n 结尾则补回(splice 循环每行均加 \n,
|
||
// 仅当 new_text 区间恰好覆盖到文件末尾时可能少一个尾换行,统一处理)
|
||
// 注意:若 new_text 以 \n 结尾,splice 已带尾换行;否则原文件尾换行由末行 push('\n') 提供。
|
||
// 仅当文件完全不以换行结尾时,需去掉末尾多余 \n。
|
||
if !trailing_newline && out.ends_with('\n') {
|
||
out.pop();
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。
|
||
///
|
||
/// - 子串匹配(大小写敏感,与 search_files 一致)。
|
||
/// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。
|
||
/// - 同一行同时命中 start/end:start==end,区间退化为单行替换。
|
||
/// - 返回 (start_line, end_line)(1-based 含首尾)。
|
||
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
|
||
if start.is_empty() {
|
||
anyhow::bail!("anchor.start 不能为空");
|
||
}
|
||
if end.is_empty() {
|
||
anyhow::bail!("anchor.end 不能为空");
|
||
}
|
||
let mut start_line: Option<usize> = None;
|
||
for (i, line) in content.lines().enumerate() {
|
||
if line.contains(start) {
|
||
start_line = Some(i + 1);
|
||
break;
|
||
}
|
||
}
|
||
let start_line = start_line
|
||
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
|
||
|
||
// end 在 start_line 之后(含 start_line 同行)第一个命中的行
|
||
let mut end_line: Option<usize> = None;
|
||
for (i, line) in content.lines().enumerate() {
|
||
let lineno = i + 1;
|
||
if lineno < start_line {
|
||
continue;
|
||
}
|
||
if line.contains(end) {
|
||
end_line = Some(lineno);
|
||
break;
|
||
}
|
||
}
|
||
let end_line = end_line
|
||
.ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", end, start_line))?;
|
||
|
||
// start_line <= end_line 已由搜索顺序保证,此处断言兜底
|
||
debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
|
||
Ok((start_line, end_line))
|
||
}
|
||
|
||
/// 全局文件锁表:每个路径一把互斥锁(L1 防护,防同文件并发读写冲突)
|
||
///
|
||
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
|
||
/// 单用户桌面应用,Mutex 够用且简单(~15 行),不需要全局文件队列。
|
||
static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
|
||
LazyLock::new(|| TokioMutex::new(HashMap::new()));
|
||
|
||
/// 计算文件指纹 `modified_secs_len`(TD-260621-04 闭环)。
|
||
///
|
||
/// read_file 返回此值 → LLM 回传 patch_file.expected_hash → patch_file 比对,闭环防并发修改。
|
||
/// 抽单一真相源避免 read_file 输出与 patch_file 校验两处 format 漂移(modified_secs_len 格式必须严格一致)。
|
||
/// modified 取不到(系统不支持)退 0,仍保留 len 维度作弱保护。
|
||
fn compute_file_hash(meta: &std::fs::Metadata) -> String {
|
||
let modified = meta
|
||
.modified()
|
||
.ok()
|
||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||
.map(|d| d.as_secs());
|
||
format!("{}_{}", modified.unwrap_or(0), meta.len())
|
||
}
|
||
|
||
/// 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("."))
|
||
}
|
||
|
||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
|
||
///
|
||
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
|
||
/// (ERROR_NOT_SAME_DEVICE),同盘同卷 rename 原子。`delete_file`(源→workspace/.trash)
|
||
/// 与 `rename_file`(from→to)在跨盘场景都需降级为 copy + remove(非原子)。
|
||
///
|
||
/// 本 helper 统一两处降级逻辑(对齐 delete_file:1430 与 rename_file:1534 的 copy+remove 模式):
|
||
/// 1. 先试 `rename`(原子,同盘成功直接返 Ok);
|
||
/// 2. rename 失败且 `raw_os_error == Some(17)`(跨卷)→ 降级 copy + remove,
|
||
/// copy 失败源完整(未动,上抛);remove 失败删 target 回滚保源完整(对齐设计:失败回滚删 target);
|
||
/// 3. rename 失败但非 17(权限/占用)→ 上抛原错,不降级(非跨卷问题降级无意义)。
|
||
///
|
||
/// 返回 `cross_volume: bool`(rename 成功=false,降级 copy+remove=true)供调用方回填结果。
|
||
///
|
||
/// 兜底/回退:flag 关或 helper 内部 panic 不影响——降级是纯增强,失败上抛 anyhow 让调用方
|
||
/// 走原 failed tool_result 路径(LLM 据此修参重试,非死循环:重试由阶段4 retry_guard 兜底)。
|
||
async fn rename_or_cross_volume_copy(
|
||
from: &str,
|
||
to: &str,
|
||
) -> anyhow::Result<bool> {
|
||
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
|
||
match tokio::fs::rename(from, to).await {
|
||
Ok(()) => Ok(false),
|
||
Err(err) => {
|
||
// 跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove;其他错误(权限/占用)直接抛
|
||
let cross_volume = err.raw_os_error() == Some(17);
|
||
if !cross_volume {
|
||
anyhow::bail!("重命名/移动失败: {}", err);
|
||
}
|
||
// 跨卷降级 copy + remove(非原子):copy 失败 from 完整(未动);copy 成功 remove 失败
|
||
// 则 from/to 同时存在,删 to 回滚保 from 完整(对齐设计:失败回滚删 to)
|
||
if let Err(e) = tokio::fs::copy(from, to).await {
|
||
anyhow::bail!("跨卷复制失败(from 未改动): {}", e);
|
||
}
|
||
if let Err(e) = tokio::fs::remove_file(from).await {
|
||
// remove 失败:回滚删 to,保 from 完整(用户可重试)
|
||
let _ = tokio::fs::remove_file(to).await;
|
||
anyhow::bail!("跨卷移动删除源失败已回滚(from 完整,可重试): {}", e);
|
||
}
|
||
Ok(true)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
|
||
///
|
||
/// 双层校验:
|
||
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
|
||
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
|
||
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
|
||
///
|
||
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
|
||
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
|
||
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
|
||
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
|
||
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
|
||
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
|
||
fn resolve_workspace_path_with_allowed(
|
||
path: &str,
|
||
allowed: &AllowedDirs,
|
||
) -> anyhow::Result<PathBuf> {
|
||
resolve_workspace_path_impl(path, allowed)
|
||
}
|
||
|
||
/// AE-2025-04:mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
|
||
/// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数
|
||
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
|
||
///
|
||
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
|
||
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
|
||
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
|
||
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
|
||
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
|
||
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
|
||
}
|
||
|
||
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> 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)
|
||
};
|
||
// 词法层:防明显越界(不存在路径的兜底)。
|
||
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
|
||
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
|
||
if !allowed.is_authorized(&resolved) {
|
||
anyhow::bail!("路径不在授权目录内: {}", path);
|
||
}
|
||
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
|
||
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
|
||
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
|
||
if resolved.exists() {
|
||
let canon_resolved = resolved.canonicalize()?;
|
||
if !allowed.is_authorized(&canon_resolved) {
|
||
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+实现同源,消除双轨)
|
||
///
|
||
/// SMELL-P0-2 拆分:原 1091 行单函数拆为两层注册。
|
||
/// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo。
|
||
/// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。
|
||
/// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。
|
||
///
|
||
/// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc<RwLock<AllowedDirs>>` 形参,
|
||
/// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。
|
||
/// data/http 工具不涉及文件路径,不传白名单。
|
||
pub fn build_ai_tool_registry(
|
||
db: &Arc<Database>,
|
||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||
) -> AiToolRegistry {
|
||
let mut registry = AiToolRegistry::new();
|
||
register_data_tools(&mut registry, db);
|
||
register_file_tools(&mut registry, allowed_dirs);
|
||
register_http_tools(&mut registry);
|
||
registry
|
||
}
|
||
|
||
/// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
|
||
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
|
||
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
|
||
///
|
||
/// 注意:一个工具名两种 risk 不支持(register 单一 risk_level),故按最高风险 High 注册。
|
||
// (写方法 High 兜底;GET 也走 High 审批更保守 — 宁可多审批不漏副作用。)
|
||
// 设计权衡见 http.rs 模块注释。若未来需 GET 走 Medium 自动,需拆 get_request/post_request 两工具。
|
||
fn register_http_tools(registry: &mut AiToolRegistry) {
|
||
registry.register(
|
||
"http_request", "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
|
||
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼
|
||
{
|
||
let mut props = serde_json::Map::new();
|
||
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
|
||
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
|
||
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
|
||
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
|
||
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
|
||
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
|
||
serde_json::json!({
|
||
"type": "object",
|
||
"properties": props,
|
||
"required": ["url"],
|
||
})
|
||
},
|
||
RiskLevel::High,
|
||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
|
||
super::http::execute_http_request(args).await
|
||
})),
|
||
);
|
||
}
|
||
|
||
/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。
|
||
///
|
||
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。
|
||
/// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经
|
||
/// ai_approve → run_workflow_inner 另走完整 State 路径。
|
||
///
|
||
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
|
||
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
|
||
///
|
||
/// SMELL-P0-2 续拆(2026-06-19):18 工具按业务表细分为 5 子函数:
|
||
/// - register_project_tools(8):list/update/create/bind/delete/restore/purge/get_count
|
||
/// - register_task_tools(6):list/create/update/advance/delete/get_count
|
||
/// - register_workflow_tools(1):run_workflow(High,单独,handler 防御兜底)
|
||
/// - register_idea_tools(2):list/create
|
||
/// - register_trash_tools(1):list_trash
|
||
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致,
|
||
/// 行为零变更(基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
|
||
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
register_project_tools(registry, db);
|
||
register_task_tools(registry, db);
|
||
register_workflow_tools(registry);
|
||
register_idea_tools(registry, db);
|
||
register_trash_tools(registry, db);
|
||
}
|
||
|
||
/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
/// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。
|
||
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
registry.register(
|
||
"list_projects", "列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
|
||
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", 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::ProjectRepo::new(&db);
|
||
let items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
|
||
let total = items.len();
|
||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||
let has_more = (offset + page_items.len()) < total;
|
||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||
})
|
||
})},
|
||
);
|
||
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?;
|
||
// F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory
|
||
// 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs
|
||
// (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。
|
||
// 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。
|
||
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
|
||
})
|
||
})},
|
||
);
|
||
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 }))
|
||
})
|
||
})},
|
||
);
|
||
// ── 探总量工具 (Low risk, F-260616-08) ──
|
||
registry.register(
|
||
"get_project_count", "获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
|
||
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 items = repo.list_active().await?;
|
||
Ok(serde_json::json!({ "total": items.len() }))
|
||
})
|
||
})},
|
||
);
|
||
}
|
||
|
||
/// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
/// 组内顺序保留原相对顺序(list/create/update/advance/delete/get_count)。
|
||
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
registry.register(
|
||
"list_tasks", "列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||
df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", 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?
|
||
};
|
||
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
|
||
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
|
||
tasks.retain(|t| t.status == status);
|
||
}
|
||
let total = tasks.len();
|
||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||
let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
|
||
let has_more = (offset + page_items.len()) < total;
|
||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"create_task", "在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)",
|
||
df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false), ("idea_id", "string", 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,
|
||
review_rounds: 0,
|
||
output_json: None,
|
||
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
|
||
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
|
||
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/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
|
||
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"))?;
|
||
// F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进),
|
||
// 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。
|
||
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
|
||
if field == "status" {
|
||
anyhow::bail!(
|
||
"status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
|
||
);
|
||
}
|
||
// 复用 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?;
|
||
// UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord,
|
||
// 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。
|
||
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
|
||
Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": true }))
|
||
})
|
||
})},
|
||
);
|
||
// F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。
|
||
// D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落
|
||
// df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC
|
||
// commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。
|
||
// 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。
|
||
registry.register(
|
||
"advance_task", "推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
|
||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("target_status", "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 target_status = args["target_status"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
||
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
|
||
// 与 commands::task::advance_task IPC 同源,避免双轨。
|
||
let repo = df_storage::crud::TaskRepo::new(&db);
|
||
let updated = df_nodes::task_advance_node::advance_task_atomic(
|
||
&repo, id, target_status,
|
||
).await?;
|
||
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
|
||
Ok(serde_json::to_value(&updated)?)
|
||
})
|
||
})},
|
||
);
|
||
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
|
||
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
|
||
registry.register(
|
||
"delete_task", "删除任务(移入回收站,可恢复)",
|
||
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.soft_delete(id).await?;
|
||
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
|
||
})
|
||
})},
|
||
);
|
||
// ── 探总量工具 (Low risk, F-260616-08) ──
|
||
registry.register(
|
||
"get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
|
||
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 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?
|
||
};
|
||
Ok(serde_json::json!({ "total": tasks.len() }))
|
||
})
|
||
})},
|
||
);
|
||
}
|
||
|
||
/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
||
// F-260616-07 阶段3(子项2):run_workflow AI 工具注册实装。
|
||
// 实施路径文档 §三 列为阶段3 必做项(tool_registry.rs 此前无此工具连空壳都没有)。
|
||
// 描述明确按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。
|
||
//
|
||
// handler 约束说明(B-260617-01 更新):run_workflow 真正执行需要 AppHandle(转发 workflow-event
|
||
// 到前端) + AppState(registry 构建 DAG / event_bus 订阅 / workflows Repo 落库 /
|
||
// workflow_state_registry 注销),这些在 tool handler(仅持 db: Arc<Database>)中无法构造。
|
||
//
|
||
// **执行路径(单一)**:run_workflow 是 High risk → 始终经 audit.rs:process_tool_calls 进 pending
|
||
// → ai_approve 审批 → commands.rs ai_approve 内识别 run_workflow 分支调
|
||
// execute_run_workflow_for_tool(mod.rs)→ workflow.rs::run_workflow_inner(持完整 State)真正执行。
|
||
// 故本 handler 经 ai_tools.execute 调用的路径在正常流程下不可达(dead code 防御):
|
||
// 仅当未来出现"不经 ai_approve 直接 execute run_workflow"的异常调用方时,本 Err 作为防御兜底
|
||
// 返回明确错误,而非静默 panic。LLM 误判重试同 tool_call 时,High risk 去重缓存
|
||
// (audit.rs:find_cached_high_risk_result)会复用旧 tool_result 跳过审批,断重试循环。
|
||
registry.register(
|
||
"run_workflow", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
|
||
df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
|
||
RiskLevel::High,
|
||
// CR-52: 删死代码 `let _db = db.clone();`(完全未用 — 防御 handler 仅返回 Err,
|
||
// 不持 db;run_workflow 真正执行经 ai_approve → execute_run_workflow_for_tool
|
||
// → workflow.rs::run_workflow_inner,此处 handler 在正常流程不可达)。
|
||
{ Box::new(move |args: serde_json::Value| {
|
||
Box::pin(async move {
|
||
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
|
||
let target_status = args["target_status"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
|
||
// 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
|
||
// 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
|
||
// (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
|
||
Err(anyhow::anyhow!(
|
||
"run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
|
||
持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
|
||
勿盲目重试同调用(task_id={}, target_status={});\
|
||
若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
|
||
task_id, target_status
|
||
))
|
||
})
|
||
})},
|
||
);
|
||
}
|
||
|
||
/// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
registry.register(
|
||
"list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", 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::IdeaRepo::new(&db);
|
||
let items = repo.list_all().await?;
|
||
let total = items.len();
|
||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||
let has_more = (offset + page_items.len()) < total;
|
||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||
})
|
||
})},
|
||
);
|
||
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,
|
||
related_ids: 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" }))
|
||
})
|
||
})},
|
||
);
|
||
}
|
||
|
||
/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
registry.register(
|
||
"list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
|
||
df_ai::ai_tools::object_schema(vec![("offset", "integer", false), ("limit", "integer", 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::ProjectRepo::new(&db);
|
||
let items = repo.list_deleted().await?;
|
||
let total = items.len();
|
||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
|
||
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
|
||
let has_more = (offset + page_items.len()) < total;
|
||
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
|
||
})
|
||
})},
|
||
);
|
||
}
|
||
|
||
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
|
||
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
|
||
///
|
||
/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`,
|
||
/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。
|
||
/// Phase A 仅持久化白名单(workspace_root 始终授权,无 allowed_dirs 配置时行为不变)。
|
||
fn register_file_tools(
|
||
registry: &mut AiToolRegistry,
|
||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||
) {
|
||
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
|
||
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
|
||
// 故该工具闭包不捕获 allowed_dirs,维持原行为。
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
|
||
// ── 文件系统 ──
|
||
registry.register(
|
||
"read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
|
||
RiskLevel::Low,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
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());
|
||
}
|
||
// TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。
|
||
// 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。
|
||
let file_hash = compute_file_hash(&metadata);
|
||
// 二进制/非 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(), "file_hash": file_hash,
|
||
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
|
||
}));
|
||
}
|
||
anyhow::bail!("读取文件失败: {}", e);
|
||
}
|
||
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
|
||
if let Some(search) = args["search"].as_str() {
|
||
const SEARCH_MAX: usize = 50;
|
||
let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
|
||
// 先收集全部匹配行用于 total 计数,再做 skip/take 分页
|
||
let mut all_matches: Vec<serde_json::Value> = Vec::new();
|
||
for (idx, line) in content.lines().enumerate() {
|
||
if line.contains(search) {
|
||
all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
|
||
}
|
||
}
|
||
let total = all_matches.len();
|
||
let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
|
||
let has_more = (search_offset + page_matches.len()) < total;
|
||
return Ok(serde_json::json!({
|
||
"path": path, "size": metadata.len(), "file_hash": file_hash,
|
||
"search": search,
|
||
"matches": page_matches,
|
||
"total": total,
|
||
"has_more": has_more,
|
||
}));
|
||
}
|
||
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
|
||
// BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件,
|
||
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
|
||
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
|
||
let line_count = content.lines().count();
|
||
let (result, offset_used, has_more) = 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;
|
||
let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
|
||
let more = (skip + page.len()) < line_count;
|
||
(page.join("\n"), Some(skip), more)
|
||
} else {
|
||
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
|
||
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
|
||
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
|
||
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||
const DEFAULT_PREVIEW_LINES: usize = 500;
|
||
let limit = args["limit"].as_u64()
|
||
.unwrap_or(DEFAULT_PREVIEW_LINES as u64)
|
||
.min(2000) as usize;
|
||
let page: Vec<&str> = content.lines().take(limit).collect();
|
||
let more = line_count > page.len();
|
||
(page.join("\n"), None, more)
|
||
};
|
||
Ok(serde_json::json!({
|
||
"path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
|
||
"offset": offset_used,
|
||
"returned_lines": result.lines().count(),
|
||
"has_more": has_more,
|
||
}))
|
||
})
|
||
})},
|
||
);
|
||
registry.register(
|
||
"read_symbol", "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false)]),
|
||
RiskLevel::Low,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?;
|
||
let full = args["full"].as_bool().unwrap_or(false);
|
||
let kind_hint = args["kind"].as_str();
|
||
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());
|
||
}
|
||
let file_hash = compute_file_hash(&metadata);
|
||
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, "binary": true, "size": metadata.len(), "file_hash": file_hash,
|
||
"fallback": true, "reason": "binary",
|
||
"suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
|
||
}));
|
||
}
|
||
anyhow::bail!("读取文件失败: {}", e);
|
||
}
|
||
// 调 code_intel 纯函数(三态 + 兜底,不 panic)
|
||
Ok(crate::commands::ai::code_intel::read_symbol(
|
||
&content, &file_hash, path, symbol, full, kind_hint,
|
||
))
|
||
})
|
||
})},
|
||
);
|
||
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,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
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);
|
||
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
|
||
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
|
||
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) 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", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
|
||
df_ai::ai_tools::object_schema(vec![
|
||
("path", "string", true),
|
||
("content", "string", true),
|
||
("encoding", "string", false),
|
||
]),
|
||
RiskLevel::Medium,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
|
||
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
|
||
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
|
||
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
|
||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||
STANDARD.decode(raw)
|
||
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
|
||
} else if encoding.eq_ignore_ascii_case("utf-8") {
|
||
raw.as_bytes().to_vec()
|
||
} else {
|
||
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64)", encoding);
|
||
};
|
||
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
|
||
if bytes.len() > 1_048_576 {
|
||
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.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 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
|
||
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
|
||
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
|
||
if !snap.is_authorized(parent) {
|
||
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, &bytes).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 && (bytes.len() as f64 / old as f64) < 0.1 {
|
||
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
|
||
}
|
||
}
|
||
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 局部文件编辑 (Medium risk) ──
|
||
// F-260617-01: 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点)。
|
||
// object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。
|
||
// 三模式均需 path + new_text;old_text/replace_lines/anchor 三选一(互斥);expected_hash 可选通用。
|
||
let patch_file_schema = {
|
||
let mut props = serde_json::Map::new();
|
||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" }));
|
||
props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" }));
|
||
props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" }));
|
||
props.insert("replace_lines".into(), serde_json::json!({
|
||
"type": "object",
|
||
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移",
|
||
"properties": {
|
||
"start": { "type": "integer", "description": "起始行号(1-based,含)" },
|
||
"end": { "type": "integer", "description": "结束行号(1-based,含)" }
|
||
},
|
||
"required": ["start", "end"]
|
||
}));
|
||
props.insert("anchor".into(), serde_json::json!({
|
||
"type": "object",
|
||
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
|
||
"properties": {
|
||
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
|
||
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
|
||
},
|
||
"required": ["start", "end"]
|
||
}));
|
||
props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "(old_text 模式)行号辅助定位,可选" }));
|
||
props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹(read_file 返回的 file_hash),三模式通用,防并发修改" }));
|
||
serde_json::json!({
|
||
"type": "object",
|
||
"properties": props,
|
||
"required": ["path", "new_text"],
|
||
})
|
||
};
|
||
registry.register(
|
||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
||
patch_file_schema,
|
||
RiskLevel::Medium,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let new_text = args["new_text"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
|
||
|
||
// F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor)
|
||
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
|
||
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
|
||
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
|
||
let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
|
||
let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count();
|
||
if mode_count == 0 {
|
||
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
|
||
}
|
||
if mode_count > 1 {
|
||
anyhow::bail!(
|
||
"模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)",
|
||
mode_count
|
||
);
|
||
}
|
||
|
||
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
|
||
let target = std::path::Path::new(path);
|
||
if !target.exists() {
|
||
anyhow::bail!("文件不存在: {}", path);
|
||
}
|
||
let file_meta = tokio::fs::metadata(target).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||
if file_meta.len() > 1_048_576 {
|
||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
|
||
}
|
||
|
||
// TD-260621-03:读改写整体锁内防 lost update。
|
||
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
|
||
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
|
||
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
|
||
// 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。
|
||
let _patch_guard = FILE_LOCKS.lock().await;
|
||
|
||
// 读文件内容 + 校验(锁内,纯读 + CPU 计算)
|
||
use tokio::io::AsyncReadExt;
|
||
let mut file = tokio::fs::File::open(path).await
|
||
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
|
||
let mut content = String::new();
|
||
file.read_to_string(&mut content).await
|
||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||
|
||
// 二进制检测
|
||
if content.contains('\0') {
|
||
anyhow::bail!("不支持二进制文件");
|
||
}
|
||
|
||
// L3: expected_hash 指纹校验(防外部修改,TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式)
|
||
if let Some(expected) = args["expected_hash"].as_str() {
|
||
let current_hash = compute_file_hash(&file_meta);
|
||
if current_hash != expected {
|
||
anyhow::bail!(
|
||
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
|
||
expected, current_hash
|
||
);
|
||
}
|
||
}
|
||
|
||
// F-260617-01: 三模式分派计算 new_content + match_count + warning
|
||
// old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处)
|
||
// replace_lines 模式:行号区间 splice(越界 Err)
|
||
// anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err)
|
||
let new_content: String;
|
||
let match_count: usize;
|
||
let warning: Option<String>;
|
||
|
||
if has_old_text {
|
||
let old_text = args["old_text"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
|
||
if old_text.is_empty() {
|
||
anyhow::bail!("old_text 不能为空");
|
||
}
|
||
if old_text == new_text {
|
||
return Ok(serde_json::json!({
|
||
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
|
||
}));
|
||
}
|
||
// L2: old_text 精确匹配(CAS 语义)
|
||
if !content.contains(old_text) {
|
||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||
}
|
||
let mc = content.matches(old_text).count();
|
||
match_count = mc;
|
||
warning = if mc > 1 {
|
||
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
|
||
} else { None };
|
||
new_content = content.replacen(old_text, new_text, 1);
|
||
} else if has_replace_lines {
|
||
let rl = args.get("replace_lines")
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
|
||
let start = rl["start"].as_u64()
|
||
.ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize;
|
||
let end = rl["end"].as_u64()
|
||
.ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize;
|
||
new_content = apply_line_range(&content, start, end, new_text)?;
|
||
match_count = 1;
|
||
warning = None;
|
||
} else {
|
||
// has_anchor
|
||
let an = args.get("anchor")
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
|
||
let a_start = an["start"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?;
|
||
let a_end = an["end"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?;
|
||
let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?;
|
||
new_content = apply_line_range(&content, start_line, end_line, new_text)?;
|
||
match_count = 1;
|
||
warning = None;
|
||
}
|
||
|
||
// 写序列(_patch_guard 持锁中:backup → tmp write → rename → cleanup)
|
||
// .bak 备份
|
||
let bak = format!("{}.bak", path);
|
||
tokio::fs::copy(path, &bak).await
|
||
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
|
||
|
||
// 原子写: tmp → rename
|
||
let tmp = format!("{}.tmp-write", path);
|
||
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
|
||
let _ = tokio::fs::remove_file(&tmp).await;
|
||
let _ = tokio::fs::remove_file(&bak).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, bak));
|
||
}
|
||
// 成功:清理 .bak
|
||
let _ = tokio::fs::remove_file(&bak).await;
|
||
drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁)
|
||
|
||
let size_diff = new_content.len() as i64 - content.len() as i64;
|
||
// 生成 unified diff 供前端审批卡/审计留痕展示
|
||
// content(旧)与 new_content(新)作用域内可用,无需重读文件
|
||
let diff = generate_diff(&content, &new_content);
|
||
let mut result = serde_json::json!({
|
||
"path": path,
|
||
"changed": true,
|
||
"size_diff": size_diff,
|
||
"matches_found": match_count,
|
||
"diff": diff,
|
||
});
|
||
if let Some(w) = warning {
|
||
result["warning"] = serde_json::Value::String(w);
|
||
}
|
||
Ok(result)
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 文件元信息 (Low risk) ──
|
||
registry.register(
|
||
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
|
||
RiskLevel::Low,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
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 (BUG-260617-02)
|
||
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
|
||
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
|
||
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
|
||
// size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。
|
||
let is_binary = if !is_dir && size > 0 {
|
||
use tokio::io::AsyncReadExt;
|
||
let file = match tokio::fs::File::open(path).await {
|
||
Ok(f) => f,
|
||
Err(_) => return Ok(serde_json::json!({
|
||
"path": path, "exists": true, "size": size, "lines": serde_json::Value::Null,
|
||
"modified": modified, "is_binary": false, "is_dir": is_dir,
|
||
"error": "读取文件头失败"
|
||
})),
|
||
};
|
||
let mut reader = tokio::io::BufReader::new(file);
|
||
let mut sample = vec![0u8; 8192];
|
||
let n = reader.read(&mut sample).await.unwrap_or(0);
|
||
sample[..n].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,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
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() {
|
||
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||
if !snap.is_authorized(parent) {
|
||
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 }))
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 删除文件 (High risk, F-260615-16) ──
|
||
registry.register(
|
||
"delete_file", "删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准",
|
||
df_ai::ai_tools::object_schema(vec![
|
||
("path", "string", true),
|
||
("permanent", "boolean", false),
|
||
]),
|
||
RiskLevel::High,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
let target = std::path::Path::new(path);
|
||
if !target.exists() {
|
||
anyhow::bail!("文件不存在: {}", path);
|
||
}
|
||
// 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
|
||
let metadata = tokio::fs::metadata(target).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||
if metadata.is_dir() {
|
||
anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path);
|
||
}
|
||
let file_name = target.file_name()
|
||
.ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
|
||
.to_string_lossy().to_string();
|
||
|
||
if permanent {
|
||
// 硬删除:不可逆,对齐 delete_project purge 语义
|
||
tokio::fs::remove_file(path).await
|
||
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
|
||
Ok(serde_json::json!({
|
||
"path": path,
|
||
"deleted": true,
|
||
"permanent": true,
|
||
"backed_up": false,
|
||
}))
|
||
} else {
|
||
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
|
||
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
|
||
let trash_dir = workspace_root().join(".trash");
|
||
tokio::fs::create_dir_all(&trash_dir).await
|
||
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
|
||
let backup_name = format!("{}-{}", new_id(), file_name);
|
||
let backup_path = trash_dir.join(&backup_name);
|
||
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
|
||
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子,
|
||
// 失败回滚删 backup 保源完整)。
|
||
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
|
||
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
|
||
// (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。
|
||
// .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。
|
||
// helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。
|
||
if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await {
|
||
anyhow::bail!("移入回收站失败({})", e);
|
||
}
|
||
Ok(serde_json::json!({
|
||
"path": path,
|
||
"deleted": true,
|
||
"permanent": false,
|
||
"backed_up": true,
|
||
"backup_path": backup_path_str,
|
||
}))
|
||
}
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
|
||
// 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
|
||
// 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
|
||
registry.register(
|
||
"rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准",
|
||
df_ai::ai_tools::object_schema(vec![
|
||
("from", "string", true),
|
||
("to", "string", true),
|
||
("overwrite", "boolean", false),
|
||
]),
|
||
RiskLevel::Medium,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed,
|
||
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let from_resolved = resolve_workspace_path_with_allowed(
|
||
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
|
||
let to_resolved = resolve_workspace_path_with_allowed(
|
||
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
|
||
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
|
||
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
|
||
let from_target = std::path::Path::new(from_path);
|
||
if !from_target.exists() {
|
||
anyhow::bail!("源文件不存在: {}", from_path);
|
||
}
|
||
let from_metadata = tokio::fs::metadata(from_target).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
|
||
if from_metadata.is_dir() {
|
||
anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path);
|
||
}
|
||
let bytes_moved = from_metadata.len();
|
||
|
||
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
||
let to_target = std::path::Path::new(to_path);
|
||
if let Some(parent) = to_target.parent() {
|
||
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||
if !snap.is_authorized(parent) {
|
||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||
}
|
||
tokio::fs::create_dir_all(parent).await
|
||
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
|
||
}
|
||
|
||
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
|
||
if to_target.exists() {
|
||
if !overwrite {
|
||
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
|
||
}
|
||
tokio::fs::remove_file(to_path).await
|
||
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
|
||
}
|
||
|
||
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
|
||
// 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级),
|
||
// 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。
|
||
let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?;
|
||
Ok(serde_json::json!({
|
||
"from": from_path,
|
||
"to": to_path,
|
||
"renamed": true,
|
||
"bytes_moved": bytes_moved,
|
||
"cross_volume": cross_volume,
|
||
}))
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 跨文件内容搜索 grep (Medium risk, F-260621) ──
|
||
// 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。
|
||
// 参考 memory [[devflow-patch-file-design]] 工具落地模式 + Claude Code grep 工具语义。
|
||
//
|
||
// 参数语义(对齐 Claude Code grep):
|
||
// - pattern(必填):正则表达式(默认),大小写敏感。-i 切大小写不敏感。
|
||
// 注意:与 search_files 不同(search_files 字面包含 + 大小写不敏感),grep 用 regex 更强表达力,
|
||
// 但"无特殊字符的 pattern"等价字面包含(如 "foo" 匹配含 foo 的行)。
|
||
// - path(搜索根):锚 workspace + path_auth 授权(resolve_workspace_path_with_allowed)。
|
||
// - glob(可选):文件名过滤,如 "*.rs"。支持基础 glob(* / ? / [seq] 单段),复用 PatternBuilder。
|
||
// - output_mode:content(默认,返 file:line:content+context) / files_with_matches(只返命中文件名) /
|
||
// count(每文件命中行数)。对齐 Claude Code grep 三模式。
|
||
// - -n(行号,默认 true)/ -i(大小写不敏感,默认 false)/ -C context_lines(上下文行数,默认 0)。
|
||
// - max_results:防撑爆 LLM context,默认 50(对齐 read_file search/search_files 50 条上限)。
|
||
//
|
||
// 安全:递归遍历跳过噪音目录(.git/node_modules/target/.trash 等 is_noise_dir)+
|
||
// 噪音文件(.bak/.tmp-write 等 is_noise_file)+ symlink(对齐 list_dir_recursive 防逃逸)+
|
||
// 二进制文件(对齐 read_file/list_directory:\0 检测)。
|
||
// path_auth:授权目录内放行;未授权走 AiDirAuthRequired 申请(audit/mod.rs check_file_tool_auth
|
||
// 经 extract_file_tool_paths 单路径分支触发,非 search_files 盲拒)。
|
||
// risk:Med(读文件内容,授权目录内放行/外申请)。
|
||
// 注册顺序:read_file/list_directory 后,search_files 前(高频检索工具靠前)。
|
||
let grep_schema = {
|
||
let mut props = serde_json::Map::new();
|
||
props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" }));
|
||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。必填" }));
|
||
props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" }));
|
||
props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] }));
|
||
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
|
||
props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" }));
|
||
props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 }));
|
||
props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 }));
|
||
serde_json::json!({
|
||
"type": "object",
|
||
"properties": props,
|
||
"required": ["pattern", "path"],
|
||
})
|
||
};
|
||
registry.register(
|
||
"grep", "跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,锚 workspace + path_auth 授权)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||
grep_schema,
|
||
RiskLevel::Medium,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let pattern = args["pattern"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||
if pattern.is_empty() {
|
||
anyhow::bail!("pattern 不能为空");
|
||
}
|
||
let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||
let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
|
||
let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize;
|
||
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
|
||
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
|
||
|
||
// 编译正则:case_insensitive 开 i flag;失败上抛明确错误(非法正则不是业务错,LLM 据此修参)
|
||
let mut re_builder = regex::RegexBuilder::new(pattern);
|
||
re_builder.case_insensitive(case_insensitive);
|
||
let re = re_builder.build()
|
||
.map_err(|e| anyhow::anyhow!("正则编译失败「{}」: {}", pattern, e))?;
|
||
|
||
// glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。
|
||
// 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。
|
||
let glob_re = match glob_opt {
|
||
Some(g) => Some(compile_glob_to_regex(g)
|
||
.map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?),
|
||
None => None,
|
||
};
|
||
|
||
// 递归遍历+逐文件读+行匹配,收集结果
|
||
let mut matches_out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total: usize = 0;
|
||
let mut truncated = false;
|
||
grep_recursive(
|
||
root, &re, glob_re.as_ref(), output_mode,
|
||
context_lines, max_results, 0, 6,
|
||
&mut matches_out, &mut total, &mut truncated,
|
||
).await?;
|
||
|
||
// output_mode 分派返回结构
|
||
let result = match output_mode {
|
||
"files_with_matches" => {
|
||
// 仅返命中文件路径列表(去重,顺序保留首次命中)
|
||
let files: Vec<String> = matches_out.iter()
|
||
.map(|h| h.file.clone())
|
||
.collect();
|
||
serde_json::json!({
|
||
"path": root,
|
||
"pattern": pattern,
|
||
"output_mode": output_mode,
|
||
"files": files,
|
||
"total": files.len(),
|
||
"truncated": truncated,
|
||
})
|
||
}
|
||
"count" => {
|
||
// 每文件命中行数
|
||
let counts: Vec<serde_json::Value> = matches_out.iter()
|
||
.map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() }))
|
||
.collect();
|
||
let total_files = counts.len();
|
||
serde_json::json!({
|
||
"path": root,
|
||
"pattern": pattern,
|
||
"output_mode": output_mode,
|
||
"counts": counts,
|
||
"total": total,
|
||
"total_files": total_files,
|
||
"truncated": truncated,
|
||
})
|
||
}
|
||
_ => {
|
||
// content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}]
|
||
let mut lines: Vec<serde_json::Value> = Vec::new();
|
||
for hit in &matches_out {
|
||
for lm in &hit.line_matches {
|
||
let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null };
|
||
let mut entry = serde_json::json!({
|
||
"file": hit.file,
|
||
"line": line_no,
|
||
"content": lm.content,
|
||
});
|
||
if context_lines > 0 && !lm.context.is_empty() {
|
||
entry["context"] = serde_json::Value::String(lm.context.clone());
|
||
}
|
||
lines.push(entry);
|
||
}
|
||
}
|
||
serde_json::json!({
|
||
"path": root,
|
||
"pattern": pattern,
|
||
"output_mode": "content",
|
||
"matches": lines,
|
||
"total": total,
|
||
"truncated": truncated,
|
||
})
|
||
}
|
||
};
|
||
Ok(result)
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 文件搜索 (Low risk) ──
|
||
registry.register(
|
||
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
|
||
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||
RiskLevel::Low,
|
||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||
let allowed_dirs = allowed_dirs.clone();
|
||
Box::pin(async move {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
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 offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
|
||
// 先收集全部结果用于 total 计数,再做 skip/take 分页
|
||
let mut all_results = Vec::new();
|
||
let mut total = 0u64;
|
||
// 用较大上限收集全量(分页由内存 skip/take 控制)
|
||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
|
||
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
|
||
let has_more = (offset + page_results.len()) < total as usize;
|
||
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
|
||
})
|
||
})},
|
||
);
|
||
|
||
// ── 命令执行(run_command 放 File 层最后,降低 LLM 偏好:靠前注册易被 LLM 优先选中,
|
||
// 移到末位让 read_file/write_file 等高频文件工具在前,run_command 仅命令执行场景才该用) ──
|
||
registry.register(
|
||
"run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 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 可通过 args timeout_secs 覆盖
|
||
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
||
|
||
let request = ShellRequest {
|
||
command: command.to_string(),
|
||
working_dir: Some(working_dir.clone()),
|
||
env: HashMap::new(),
|
||
timeout_secs: Some(timeout_secs),
|
||
shell_type: Default::default(),
|
||
};
|
||
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
|
||
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
|
||
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
|
||
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
|
||
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
|
||
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
|
||
let result = execute(request).await.map_err(|e| {
|
||
let msg = e.to_string();
|
||
if msg.contains("命令执行超时") {
|
||
anyhow::anyhow!(
|
||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
|
||
timeout_secs
|
||
)
|
||
} else {
|
||
e
|
||
}
|
||
})?;
|
||
|
||
// 输出截断:防编译输出/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,
|
||
}))
|
||
})),
|
||
);
|
||
|
||
// ── 环境感知 (Low risk, L1 agent 元能力层) ──
|
||
// detect_environment:AI 主动探测运行环境的「眼睛」(设计 §2.1 补救式感知)。
|
||
// 返回 JSON:{ os, default_shell, python_path, node_path }。
|
||
//
|
||
// 为什么需要:prompt.rs env_profile_line 只注入静态 OS+shell+解释器提示(编译期 cfg!),
|
||
// AI 不知 python 真实路径/版本/node 是否安装/GBK 还是 UTF-8,撞墙后才补救。
|
||
// 本工具让 AI 在「要做某环境相关操作前」(如跑 python 脚本/装依赖)主动探测,
|
||
// 而非靠 prompt 教(机制优先 prompt 说教,见设计核心原则)。
|
||
//
|
||
// 接入断路器(设计 §2.2):后续断路器触发(run_command 连撞 N 次引号/路径错)后,
|
||
// 系统提示会引导 AI 调本工具刷新环境认知(本工具不主动调断路器,仅提供能力)。
|
||
//
|
||
// 安全:Low risk 纯只读探测。which/where 只读 PATH 不写不删,无副作用。
|
||
// 不走白名单(探测系统 PATH,非 workspace 文件),不审批(只读)。
|
||
//
|
||
// 开关(env_probe_enabled,设计 §2.1 §8):DEVFLOW_ENV_PROBE_ENABLED=off 关闭探测,
|
||
// 返回静态 profile(编译期 OS + 默认 shell,与 env_profile_line 一致),兜底降级旧行为。
|
||
// 默认 on(空值/未设/on/1/true 均视为开)。开关 off 不致工具调用失败,只退化能力。
|
||
//
|
||
// 兜底(设计 §2.1):探测失败(超时/python 未装/which 不可用)→ 该字段 null + errors 收集原因,
|
||
// 不阻断工具返回。LLM 据此知「该能力不可用」,回退静态 profile 语义。
|
||
registry.register(
|
||
"detect_environment",
|
||
"探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
|
||
df_ai::ai_tools::object_schema(vec![]),
|
||
RiskLevel::Low,
|
||
Box::new(|_args: serde_json::Value| Box::pin(async move {
|
||
// 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
|
||
let os = if cfg!(target_os = "windows") {
|
||
"Windows"
|
||
} else if cfg!(target_os = "macos") {
|
||
"macOS"
|
||
} else if cfg!(target_os = "linux") {
|
||
"Linux"
|
||
} else {
|
||
"Unknown"
|
||
};
|
||
// 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
|
||
// 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
|
||
// 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
|
||
let default_shell = if cfg!(target_os = "windows") {
|
||
"PowerShell"
|
||
} else {
|
||
"bash"
|
||
};
|
||
|
||
// 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
|
||
// 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
|
||
let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
|
||
Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
|
||
Err(_) => true, // 未设 = 默认开
|
||
};
|
||
|
||
let mut errors: Vec<String> = Vec::new();
|
||
let (python_path, node_path) = if probe_enabled {
|
||
// Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
|
||
// probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
|
||
// Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
|
||
let py = if cfg!(target_os = "windows") {
|
||
probe_executable("python", "where python", &mut errors).await
|
||
} else {
|
||
let p3 = probe_executable("python3", "which python3", &mut errors).await;
|
||
if p3.is_some() {
|
||
p3
|
||
} else {
|
||
// python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
|
||
probe_executable("python", "which python", &mut errors).await
|
||
}
|
||
};
|
||
let node = if cfg!(target_os = "windows") {
|
||
probe_executable("node", "where node", &mut errors).await
|
||
} else {
|
||
probe_executable("node", "which node", &mut errors).await
|
||
};
|
||
(py, node)
|
||
} else {
|
||
// 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
|
||
(None, None)
|
||
};
|
||
|
||
Ok(serde_json::json!({
|
||
"os": os,
|
||
"default_shell": default_shell,
|
||
"python_path": python_path,
|
||
"node_path": node_path,
|
||
"probe_enabled": probe_enabled,
|
||
"errors": errors,
|
||
}))
|
||
})),
|
||
);
|
||
}
|
||
|
||
/// 探测可执行文件路径(which/where 风格),8s 超时兜底。
|
||
///
|
||
/// 执行 `cmd`(如 "which python3" / "where node")取 stdout 首行(trim 后)作为路径。
|
||
/// 失败(命令不存在/解释器未装/超时/退出码非 0)记 `error` 到 `errors` 并返回 None。
|
||
///
|
||
/// 超时兜底(对齐 workflow-cargo-timeout-wrap 记忆):防 which/where 偶发卡死拖垮会话。
|
||
/// 用 df_execute::shell::execute(对齐 run_command 同源,Windows 无控制台窗口 + kill_on_drop),
|
||
/// shell_type 用 default(Windows=PowerShell / Unix=sh)。
|
||
async fn probe_executable(label: &str, cmd: &str, errors: &mut Vec<String>) -> Option<String> {
|
||
let request = ShellRequest {
|
||
command: cmd.to_string(),
|
||
working_dir: None,
|
||
env: HashMap::new(),
|
||
// 8s 超时:which/where 通常 <1s,8s 足够且不拖垮会话。超时视为不可用(记错返 None)。
|
||
timeout_secs: Some(8),
|
||
shell_type: Default::default(),
|
||
};
|
||
match execute(request).await {
|
||
Ok(result) => {
|
||
// 退出码非 0:which/where 未找到目标(如 python 未装)→ 正常情况,记错返 None
|
||
if !matches!(result.exit_code, Some(0)) {
|
||
errors.push(format!(
|
||
"{}: 探测命令退出码 {:?}({})",
|
||
label, result.exit_code, cmd
|
||
));
|
||
return None;
|
||
}
|
||
let first_line = result.stdout.lines().next().map(|s| s.trim().to_string());
|
||
match first_line {
|
||
Some(p) if !p.is_empty() => Some(p),
|
||
_ => {
|
||
errors.push(format!("{}: 探测输出为空({})", label, cmd));
|
||
None
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
errors.push(format!("{}: 探测失败({}): {}", label, cmd, e));
|
||
None
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 递归列出目录内容(最多 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)
|
||
})
|
||
}
|
||
|
||
/// 工具名 → 审批文案模板(供 build_approval_reason 使用)。
|
||
///
|
||
/// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。
|
||
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
|
||
/// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。
|
||
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||
let (template, keys): (&str, &[&str]) = match name {
|
||
"delete_project" => ("删除项目{}", &["id"]),
|
||
"restore_project" => ("从回收站恢复项目{}", &["id"]),
|
||
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
|
||
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
|
||
"bind_directory" => ("绑定目录:{}(项目{})", &["path", "id"]),
|
||
"create_task" => ("创建任务:{}(项目{})", &["title", "project_id"]),
|
||
"create_project" => ("创建项目:「{}」", &["name"]),
|
||
"create_idea" => ("捕获灵感:{}", &["title"]),
|
||
"patch_file" => ("修改文件:{}", &["path"]),
|
||
"delete_file" => ("删除文件:{}({})", &["path", "permanent"]),
|
||
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
|
||
// F-03 收口:推进链工具审批文案(advance_task/run_workflow)
|
||
"advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]),
|
||
"run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
|
||
// http_request 审批文案:method + url(看是什么请求打到哪)
|
||
"http_request" => ("HTTP 请求:{} {}", &["method", "url"]),
|
||
_ => return None,
|
||
};
|
||
Some((template, keys))
|
||
}
|
||
|
||
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
|
||
///
|
||
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
|
||
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
|
||
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
|
||
match name {
|
||
"create_task" => Some("创建任务"),
|
||
"write_file" => Some("写入文件"),
|
||
"read_file" => Some("读取文件"),
|
||
"list_directory" => Some("列出目录"),
|
||
"search_files" => Some("搜索文件"),
|
||
"create_project" => Some("创建项目"),
|
||
"update_project" => Some("修改项目"),
|
||
"delete_project" => Some("删除项目"),
|
||
"restore_project" => Some("恢复项目"),
|
||
"purge_project" => Some("永久删除项目"),
|
||
"bind_directory" => Some("绑定目录"),
|
||
"create_idea" => Some("捕获灵感"),
|
||
"update_task" => Some("修改任务"),
|
||
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
|
||
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息(UX-260618-14)
|
||
"run_workflow" => Some("触发工作流"),
|
||
"delete_task" => Some("删除任务"),
|
||
"update_idea" => Some("修改灵感"),
|
||
"delete_idea" => Some("删除灵感"),
|
||
"patch_file" => Some("修改文件"),
|
||
"append_file" => Some("追加写入"),
|
||
"delete_file" => Some("删除文件"),
|
||
"rename_file" => Some("重命名/移动"),
|
||
"http_request" => Some("发起 HTTP 请求"),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
|
||
fn is_noise_dir(name: &str) -> bool {
|
||
const NOISE_DIRS: &[&str] = &[
|
||
".git", "node_modules", "target", "dist", "build",
|
||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory
|
||
];
|
||
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))
|
||
}
|
||
|
||
// ============================================================
|
||
// grep 工具底层(F-260621)
|
||
//
|
||
// FileGrepHit 单文件命中聚合:line_matches 按行号升序。output_mode 分派时:
|
||
// - content: 展平 line_matches 为 matches[{file,line,content,context?}]
|
||
// - files_with_matches: 仅取 file 字段(顺序保留首次命中)
|
||
// - count: 取 line_matches.len() 为每文件命中行数
|
||
//
|
||
// LineMatch.content 为命中行原文(去尾换行),context 为命中行前后 context_lines 行
|
||
// 拼接(前 N 行 + 命中行 + 后 N 行,\n 连接),供前端展开查看上下文。
|
||
// ============================================================
|
||
|
||
/// 单行命中(1-based 行号 + 行内容 + 上下文)
|
||
struct LineMatch {
|
||
line: usize,
|
||
content: String,
|
||
context: String,
|
||
}
|
||
|
||
/// 单文件命中聚合(file=绝对/锚定路径,line_matches 按行号升序)
|
||
struct FileGrepHit {
|
||
file: String,
|
||
line_matches: Vec<LineMatch>,
|
||
}
|
||
|
||
/// 编译单段 glob(* / ? / [seq] / 字面字符)为 regex,锚定 ^...$ 整段匹配文件名。
|
||
///
|
||
/// 转换规则(对齐 Claude Code grep glob 单段语义,不含 /):
|
||
/// - `*` → `[^/]*`(任意非分隔符序列)
|
||
/// - `?` → `[^/]`(单个非分隔符)
|
||
/// - `[seq]` 原样保留为字符集(支持 [a-z] / [!seq] 取反)
|
||
/// - 其他字符 regex escape(防 `.+()` 等被当元字符)
|
||
///
|
||
/// 简化设计:不引入 glob crate(避免新顶层依赖),手写单段 glob→regex 转换。
|
||
/// 失败(glob 含非法 regex 构造,如未闭合 `[`)上抛,调用方 map_err 友好提示。
|
||
fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex> {
|
||
let mut out = String::with_capacity(glob.len() + 4);
|
||
out.push('^');
|
||
let mut chars = glob.chars().peekable();
|
||
while let Some(c) = chars.next() {
|
||
match c {
|
||
'*' => out.push_str("[^/]*"),
|
||
'?' => out.push_str("[^/]"),
|
||
'[' => {
|
||
// 字符集:原样保留到匹配的 ](支持开头 ] 字面 + ! 取反)
|
||
out.push('[');
|
||
// ] 在首位视为字面(POSIX glob 约定)
|
||
if matches!(chars.peek(), Some(']')) {
|
||
out.push('\\'); out.push(']');
|
||
chars.next();
|
||
}
|
||
if matches!(chars.peek(), Some('!')) {
|
||
out.push('^'); // regex 取反用 ^
|
||
chars.next();
|
||
}
|
||
while let Some(ch) = chars.next() {
|
||
if ch == ']' {
|
||
out.push(']');
|
||
break;
|
||
}
|
||
// 集合内字符 escape regex 元字符(如 ] 已由 break 处理,此处 \ - 等保留)
|
||
out.push(ch);
|
||
}
|
||
}
|
||
// regex 元字符 escape(. + ( ) | ^ $ { } \ 等)
|
||
'.' | '+' | '(' | ')' | '|' | '^' | '$' | '{' | '}' | '\\' => {
|
||
out.push('\\'); out.push(c);
|
||
}
|
||
_ => out.push(c),
|
||
}
|
||
}
|
||
out.push('$');
|
||
regex::Regex::new(&out).map_err(|e| anyhow::anyhow!(e.to_string()))
|
||
}
|
||
|
||
/// 递归跨文件内容搜索(grep -rn 模式)
|
||
///
|
||
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
|
||
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
|
||
/// - glob 过滤器(可选):命中文件名才读内容
|
||
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
|
||
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
|
||
/// - 逐行 `re.is_match`,收集命中行(context_lines>0 时附上下文)
|
||
/// - 达 `max_results` 命中行数即停止(返 `truncated=true`),`total` 仍累加全部命中数
|
||
///
|
||
/// 输出聚合到 `out`(按文件分组),`total`/`truncated` 由调用方读后构造响应。
|
||
fn grep_recursive<'a>(
|
||
dir: &'a str,
|
||
re: &'a regex::Regex,
|
||
glob: Option<&'a regex::Regex>,
|
||
output_mode: &'a str,
|
||
context_lines: usize,
|
||
max_results: usize,
|
||
depth: usize,
|
||
max_depth: usize,
|
||
out: &'a mut Vec<FileGrepHit>,
|
||
total: &'a mut usize,
|
||
truncated: &'a mut bool,
|
||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
|
||
Box::pin(async move {
|
||
let mut entries = tokio::fs::read_dir(dir).await
|
||
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", dir, e))?;
|
||
// 收集本层 entry 先排序(确定性输出,便于测试稳定 + LLM 可重现)
|
||
let mut items: Vec<std::path::PathBuf> = Vec::new();
|
||
while let Some(entry) = entries.next_entry().await? {
|
||
items.push(entry.path());
|
||
}
|
||
items.sort();
|
||
for path in items {
|
||
if *truncated {
|
||
return Ok(());
|
||
}
|
||
let file_name = match path.file_name() {
|
||
Some(n) => n.to_string_lossy().to_string(),
|
||
None => continue,
|
||
};
|
||
// file_type 不跟随 symlink(对比 metadata 会跟随);symlink 一律跳过(防逃逸,对齐 list_dir_recursive)
|
||
let file_type = match tokio::fs::symlink_metadata(&path).await {
|
||
Ok(ft) => ft,
|
||
Err(_) => continue, // 元数据读失败跳过(权限/竞态等)
|
||
};
|
||
if file_type.is_symlink() {
|
||
continue;
|
||
}
|
||
if file_type.is_dir() {
|
||
// 噪音目录(.git/node_modules/target/.trash 等)不深入
|
||
if is_noise_dir(&file_name) {
|
||
continue;
|
||
}
|
||
if depth < max_depth {
|
||
let child = path.to_string_lossy().into_owned();
|
||
grep_recursive(
|
||
&child, re, glob, output_mode, context_lines, max_results,
|
||
depth + 1, max_depth, out, total, truncated,
|
||
).await?;
|
||
}
|
||
continue;
|
||
}
|
||
// 文件:跳过噪音文件(.bak/.tmp-write 等编辑器产物)
|
||
if is_noise_file(&file_name) {
|
||
continue;
|
||
}
|
||
// glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
|
||
if let Some(g) = glob {
|
||
if !g.is_match(&file_name) {
|
||
continue;
|
||
}
|
||
}
|
||
// 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
|
||
let metadata = match tokio::fs::metadata(&path).await {
|
||
Ok(m) => m,
|
||
Err(_) => continue,
|
||
};
|
||
if metadata.len() > 1_048_576 {
|
||
continue; // >1MB 跳过(对齐 read_file 上限)
|
||
}
|
||
let bytes = match tokio::fs::read(&path).await {
|
||
Ok(b) => b,
|
||
Err(_) => continue,
|
||
};
|
||
// 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
|
||
if bytes.iter().take(8192).any(|&b| b == 0) {
|
||
continue;
|
||
}
|
||
// 转 UTF-8(lossy 容错非 UTF-8 残片,二进制已挡多数情况)
|
||
let content = String::from_utf8_lossy(&bytes);
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
// files_with_matches 模式:仅记文件级命中,不收集行详情。
|
||
// content/count 模式:行级命中,需逐行检查 max_results 截断(单文件可能多行命中,
|
||
// 超过 max_results 的行只计入 total 不入 out)。
|
||
if output_mode == "files_with_matches" {
|
||
// 检查文件数是否已达上限:达则本文件只计入 total 不入 out(设截断)
|
||
let mut has_hit = false;
|
||
for line in lines.iter() {
|
||
if re.is_match(line) {
|
||
*total += 1;
|
||
has_hit = true;
|
||
}
|
||
}
|
||
if !has_hit {
|
||
continue; // 本文件无命中
|
||
}
|
||
if out.len() >= max_results {
|
||
*truncated = true; // 已达文件数上限,只计入 total
|
||
} else {
|
||
out.push(FileGrepHit {
|
||
file: path.to_string_lossy().into_owned(),
|
||
line_matches: Vec::new(), // files_with_matches 不收集行详情
|
||
});
|
||
}
|
||
continue;
|
||
}
|
||
// content/count 模式:逐行收集,达 max_results 行数上限即截断(剩余行只计 total)
|
||
let mut line_matches: Vec<LineMatch> = Vec::new();
|
||
let mut file_has_hit = false;
|
||
// 已收集命中行累计计数(O(1) 维护):初值=前序文件累计收集数(本文件内逐次自增)
|
||
// 取代每次命中全量重算 out.iter().map(...).sum() 的 O(n²) 统计。
|
||
let mut collected = out.iter().map(|h| h.line_matches.len()).sum::<usize>();
|
||
for (idx, line) in lines.iter().enumerate() {
|
||
if re.is_match(line) {
|
||
*total += 1;
|
||
file_has_hit = true;
|
||
// 当前已收集行数达上限 → 截断,不再收集
|
||
if collected >= max_results {
|
||
*truncated = true;
|
||
} else {
|
||
let context = if context_lines > 0 {
|
||
let start = idx.saturating_sub(context_lines);
|
||
let end = (idx + context_lines + 1).min(lines.len());
|
||
lines[start..end].join("\n")
|
||
} else {
|
||
String::new()
|
||
};
|
||
line_matches.push(LineMatch {
|
||
line: idx + 1, // 1-based
|
||
content: line.to_string(),
|
||
context,
|
||
});
|
||
collected += 1; // 命中收集后累计计数自增(O(1))
|
||
}
|
||
}
|
||
}
|
||
if file_has_hit && !line_matches.is_empty() {
|
||
// 本文件有命中且有收集(全截断的文件不入 out,避免空 line_matches 污染)
|
||
out.push(FileGrepHit {
|
||
file: path.to_string_lossy().into_owned(),
|
||
line_matches,
|
||
});
|
||
}
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
|
||
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();
|
||
// file_type 不跟随 symlink(对比 entry.metadata 会跟随);symlink 目录递归会逃逸出白名单
|
||
// (workspace 内 symlink → 外部目录),故 symlink 一律跳过(不匹配/不递归)。对齐 list_dir_recursive。
|
||
let file_type = entry.file_type().await?;
|
||
if file_type.is_symlink() {
|
||
continue;
|
||
}
|
||
let is_dir = file_type.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();
|
||
let metadata = entry.metadata().await?;
|
||
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;
|
||
|
||
// ============================================================
|
||
// 工具注册基线测试(SMELL-P0-2 拆分防护)
|
||
//
|
||
// 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
|
||
// build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。
|
||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||
// ============================================================
|
||
|
||
/// build_ai_tool_registry 应注册恰好 31 个工具(18 data + 12 file + 1 http),且工具名集合稳定。
|
||
///
|
||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||
#[tokio::test]
|
||
async fn test_build_ai_tool_registry_baseline_tool_count() {
|
||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||
let db = Arc::new(db);
|
||
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
|
||
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||
|
||
// 总量基线:31(18 data + 12 file + 1 http)。拆分前后必须一致。
|
||
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
||
assert_eq!(
|
||
registry.len(),
|
||
31,
|
||
"工具总数应为 31(18 data + 12 file + 1 http),实际 {}", registry.len()
|
||
);
|
||
|
||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||
// data 层 18 个(持 db):CRUD/状态机/工作流
|
||
// file 层 12 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测
|
||
// http 层 1 个(不持 db):http_request
|
||
let mut expected: Vec<&str> = vec![
|
||
// ── data 层 (18) ──
|
||
"list_projects", "list_tasks", "list_ideas",
|
||
"update_project", "create_project", "bind_directory",
|
||
"create_task", "update_task", "advance_task",
|
||
"run_workflow", "delete_task", "create_idea",
|
||
"delete_project", "restore_project", "purge_project",
|
||
"list_trash", "get_project_count", "get_task_count",
|
||
// ── file 层 (12) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||
// detect_environment 新增 L1 环境感知 设计 §2.1)
|
||
"read_file", "list_directory", "write_file",
|
||
"patch_file", "file_info", "append_file",
|
||
"delete_file", "rename_file", "search_files", "run_command",
|
||
"grep", "detect_environment",
|
||
// ── http 层 (1) ──
|
||
"http_request",
|
||
];
|
||
expected.sort_unstable();
|
||
|
||
let mut actual: Vec<String> = registry.tool_names();
|
||
actual.sort();
|
||
|
||
assert_eq!(
|
||
actual,
|
||
expected.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
|
||
"工具名集合与基线不一致(可能漏注册或 rename)"
|
||
);
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
|
||
// ============================================================
|
||
// read_file 分页 limit 尊重测试(BUG-260623-01 回归守护)
|
||
//
|
||
// read_file handler 无 offset 分支旧实现固定 take(500),忽略 LLM 传入的 limit。
|
||
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||
// 修复:无 offset 时也读 args[limit](对齐有 offset 分支语义),未传默认 500。
|
||
//
|
||
// 经完整 AiToolRegistry.execute 调用 read_file handler(端到端覆盖 offset/limit 分支),
|
||
// 而非内联重复分页逻辑——避免测试与实现两处分页代码漂移(实现改测试不红)。
|
||
// ============================================================
|
||
|
||
/// 造 600 行文件,无 offset + limit=15 → returned_lines=15(limit 生效,旧 bug 返 500)。
|
||
#[tokio::test]
|
||
async fn test_read_file_no_offset_respects_limit() {
|
||
let tmp = std::env::temp_dir().join(format!("df_readfile_limit_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// 600 行(>500 默认,确保 limit=15 与默认 500 行为可区分;15 < 500 锁定 limit 被读)
|
||
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
|
||
let file = tmp.join("big.txt");
|
||
fs::write(&file, body).unwrap();
|
||
// persistent 存词法形态(对齐 is_authorized:candidate 经 strip_verbatim 归一词法去 \\?\ 前缀;
|
||
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
|
||
let mut persistent = std::collections::HashSet::new();
|
||
persistent.insert(tmp.clone());
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
|
||
|
||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||
let db = Arc::new(db);
|
||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
|
||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||
assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
|
||
assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// 造 600 行文件,无 offset 无 limit → 默认 500 行(DEFAULT_PREVIEW_LINES)。
|
||
#[tokio::test]
|
||
async fn test_read_file_no_offset_no_limit_defaults_500() {
|
||
let tmp = std::env::temp_dir().join(format!("df_readfile_default_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
|
||
let file = tmp.join("big.txt");
|
||
fs::write(&file, body).unwrap();
|
||
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
|
||
let mut persistent = std::collections::HashSet::new();
|
||
persistent.insert(tmp.clone());
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
|
||
|
||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||
let db = Arc::new(db);
|
||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||
let args = serde_json::json!({ "path": canon_file });
|
||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||
assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
|
||
assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
// ============================================================
|
||
// patch_file 三模式共用底层测试(F-260617-01)
|
||
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
|
||
// handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成,
|
||
// 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心;old_text 模式逻辑
|
||
// 沿用既有实现(contains + replacen),未改零回归,不在此重复。
|
||
// ============================================================
|
||
|
||
/// apply_line_range: 区间替换含首尾行(1-based)
|
||
#[test]
|
||
fn test_apply_line_range_inclusive() {
|
||
// 3 行文件(末尾无尾换行)
|
||
let content = "line1\nline2\nline3";
|
||
let out = apply_line_range(content, 2, 3, "REPLACED").unwrap();
|
||
// 第 2、3 行被替换为 REPLACED;REPLACED 不以 \n 结尾且其后无保留行 → 不补分隔 \n
|
||
assert_eq!(out, "line1\nREPLACED");
|
||
}
|
||
|
||
/// apply_line_range: 区间仅替换中间行,保留前后行
|
||
#[test]
|
||
fn test_apply_line_range_middle() {
|
||
let content = "a\nb\nc\nd\ne";
|
||
let out = apply_line_range(content, 2, 4, "X\nY").unwrap();
|
||
// 替换 2-4 行(b,c,d)为 X\nY;Y 后还有 e,需补 \n 分隔
|
||
assert_eq!(out, "a\nX\nY\ne");
|
||
}
|
||
|
||
/// apply_line_range: 区间替换到文件末尾,保留尾换行语义
|
||
#[test]
|
||
fn test_apply_line_range_tail_newline_preserved() {
|
||
// 文件以 \n 结尾
|
||
let content = "a\nb\nc\n";
|
||
let out = apply_line_range(content, 2, 3, "NEW").unwrap();
|
||
// 替换 b,c 两行为 NEW;原文件尾随 \n,NEW 不带 \n,其后无保留行
|
||
// splice 循环对 NEW(作为 start 行)push 后因不以 \n 结尾且无后续保留行 → 不补;
|
||
// 但原文件 trailing_newline=true,末尾应保留 \n
|
||
assert_eq!(out, "a\nNEW\n");
|
||
}
|
||
|
||
/// apply_line_range: 单行区间(start==end)
|
||
#[test]
|
||
fn test_apply_line_range_single_line() {
|
||
let content = "a\nb\nc";
|
||
let out = apply_line_range(content, 2, 2, "X").unwrap();
|
||
assert_eq!(out, "a\nX\nc");
|
||
}
|
||
|
||
/// apply_line_range: 多行 new_text 原样插入
|
||
#[test]
|
||
fn test_apply_line_range_multiline_newtext() {
|
||
let content = "h\nOLD\nt";
|
||
let out = apply_line_range(content, 2, 2, "n1\nn2\nn3").unwrap();
|
||
assert_eq!(out, "h\nn1\nn2\nn3\nt");
|
||
}
|
||
|
||
/// apply_line_range: start<1 越界 Err
|
||
#[test]
|
||
fn test_apply_line_range_start_below_one() {
|
||
let content = "a\nb";
|
||
let err = apply_line_range(content, 0, 1, "X").unwrap_err();
|
||
assert!(format!("{}", err).contains("start 行号必须 >= 1"), "got: {}", err);
|
||
}
|
||
|
||
/// apply_line_range: end>lines.len() 越界 Err
|
||
#[test]
|
||
fn test_apply_line_range_end_overflow() {
|
||
let content = "a\nb"; // 2 行
|
||
let err = apply_line_range(content, 1, 5, "X").unwrap_err();
|
||
assert!(format!("{}", err).contains("end 行号"), "got: {}", err);
|
||
}
|
||
|
||
/// apply_line_range: start>end Err
|
||
#[test]
|
||
fn test_apply_line_range_start_gt_end() {
|
||
let content = "a\nb\nc";
|
||
let err = apply_line_range(content, 3, 2, "X").unwrap_err();
|
||
assert!(format!("{}", err).contains("start"), "got: {}", err);
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: start/end 子串命中(基本路径)
|
||
#[test]
|
||
fn test_resolve_anchor_basic() {
|
||
// 注意 end 锚取子串;此处用 END-FN 唯一标记第 4 行(避免 println! 行内 {} 干扰)
|
||
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n} // END-FN\n";
|
||
// start 锚 "fn foo" 在第 1 行,end 锚 "END-FN" 在第 4 行
|
||
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "END-FN").unwrap();
|
||
assert_eq!((s, e), (1, 4));
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
|
||
/// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」
|
||
#[test]
|
||
fn test_resolve_anchor_substring_matches_inside_line() {
|
||
// end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 }
|
||
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n";
|
||
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
|
||
assert_eq!((s, e), (1, 3));
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: 同行命中(start==end,单行区间)
|
||
#[test]
|
||
fn test_resolve_anchor_same_line() {
|
||
let content = "header marker end here\nother";
|
||
// start/end 均在第 1 行命中
|
||
let (s, e) = resolve_anchor_to_lines(content, "header", "end").unwrap();
|
||
assert_eq!((s, e), (1, 1));
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串)
|
||
#[test]
|
||
fn test_resolve_anchor_picks_first_end_after_start() {
|
||
// 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
|
||
let content = "START\n}\nx\n}";
|
||
let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
|
||
assert_eq!((s, e), (1, 2));
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致)
|
||
#[test]
|
||
fn test_resolve_anchor_case_sensitive() {
|
||
let content = "Hello\nWorld";
|
||
// 小写 "hello" 不命中第 1 行的大写 "Hello" → Err
|
||
let err = resolve_anchor_to_lines(content, "hello", "World").unwrap_err();
|
||
assert!(format!("{}", err).contains("anchor.start 子串"), "got: {}", err);
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: start 子串找不到 Err
|
||
#[test]
|
||
fn test_resolve_anchor_start_not_found() {
|
||
let content = "a\nb\nc";
|
||
let err = resolve_anchor_to_lines(content, "NOPE", "c").unwrap_err();
|
||
assert!(format!("{}", err).contains("anchor.start 子串「NOPE」"), "got: {}", err);
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: end 子串在 start 之后找不到 Err
|
||
#[test]
|
||
fn test_resolve_anchor_end_not_found_after_start() {
|
||
// start 命中第 1 行,但 end 子串 "z" 在第 1 行之后不存在
|
||
let content = "START\nb\nc";
|
||
let err = resolve_anchor_to_lines(content, "START", "z").unwrap_err();
|
||
assert!(format!("{}", err).contains("anchor.end 子串「z」"), "got: {}", err);
|
||
}
|
||
|
||
/// resolve_anchor_to_lines: start/end 空串 Err
|
||
#[test]
|
||
fn test_resolve_anchor_empty_marker() {
|
||
let content = "a\nb";
|
||
assert!(resolve_anchor_to_lines(content, "", "b").is_err());
|
||
assert!(resolve_anchor_to_lines(content, "a", "").is_err());
|
||
}
|
||
|
||
/// 三模式组合案例:anchor 解析为行号 → apply_line_range 执行替换(端到端纯函数链)
|
||
#[test]
|
||
fn test_anchor_then_apply_line_range_e2e() {
|
||
let content = "use std::fs;\nuse std::io;\n\nfn main() {}\n";
|
||
// 锚点定位 import 区块(第 1-2 行),替换为新 import
|
||
let (s, e) = resolve_anchor_to_lines(content, "use std::fs", "use std::io").unwrap();
|
||
assert_eq!((s, e), (1, 2));
|
||
let out = apply_line_range(content, s, e, "use std::path;").unwrap();
|
||
assert_eq!(out, "use std::path;\n\nfn main() {}\n");
|
||
}
|
||
|
||
// ============================================================
|
||
// grep 工具测试(F-260621)
|
||
//
|
||
// compile_glob_to_regex:纯函数,无 fs 依赖。
|
||
// grep_recursive:递归遍历临时目录树,覆盖基本匹配/正则/大小写/glob/噪音跳过/截断。
|
||
// path_auth 未授权触发申请:经 check_file_tool_auth(单路径分支) → NeedsAuth,
|
||
// 非 search_files 盲拒(锁定 grep 走 read_file 同款申请路径)。
|
||
// ============================================================
|
||
|
||
/// compile_glob_to_regex:基础 * / ? / 字面字符
|
||
#[test]
|
||
fn test_compile_glob_basic() {
|
||
let g = compile_glob_to_regex("*.rs").unwrap();
|
||
assert!(g.is_match("main.rs"));
|
||
assert!(g.is_match("a.rs"));
|
||
assert!(!g.is_match("main.ts"));
|
||
// * 不跨段(不含 /)
|
||
assert!(!g.is_match("dir/main.rs"), "* 不匹配 / (单段语义)");
|
||
|
||
let q = compile_glob_to_regex("?.txt").unwrap();
|
||
assert!(q.is_match("a.txt"));
|
||
assert!(!q.is_match("ab.txt"), "? 仅匹配单字符");
|
||
|
||
// 字面字符(含 regex 元字符 escape:. 不当通配)
|
||
let lit = compile_glob_to_regex("v1.0.txt").unwrap();
|
||
assert!(lit.is_match("v1.0.txt"));
|
||
assert!(!lit.is_match("v1X0.txt"), ". 应字面匹配(escape 防 regex 元字符)");
|
||
}
|
||
|
||
/// compile_glob_to_regex:字符集 [seq] / [!seq] 取反
|
||
#[test]
|
||
fn test_compile_glob_charset() {
|
||
let set = compile_glob_to_regex("*.[ch]").unwrap();
|
||
assert!(set.is_match("main.c"));
|
||
assert!(set.is_match("main.h"));
|
||
assert!(!set.is_match("main.cpp"));
|
||
|
||
let neg = compile_glob_to_regex("*.[!ch]").unwrap();
|
||
assert!(!neg.is_match("main.c"), "[!ch] 取反,c 不命中");
|
||
assert!(!neg.is_match("main.h"), "[!ch] 取反,h 不命中");
|
||
// [!ch] 匹配单字符非 c/h 的扩展名(锚定 ^...$,多字符扩展不命中)
|
||
assert!(neg.is_match("main.s"), "[!ch] 取反,单字符 s 命中");
|
||
assert!(!neg.is_match("main.rs"), "[!ch] 锚定单字符,rs(2 字符)不命中");
|
||
}
|
||
|
||
/// grep_recursive:基本字面匹配(无特殊字符等价 contains)
|
||
#[tokio::test]
|
||
async fn test_grep_basic_literal_match() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_basic_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(tmp.join("src")).unwrap();
|
||
fs::write(tmp.join("src").join("a.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
|
||
fs::write(tmp.join("src").join("b.rs"), "struct Foo;\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
// 大小写敏感:只命中 a.rs 第 1 行 "fn foo() {}"(b.rs "struct Foo" 大写不命中)
|
||
assert!(!truncated);
|
||
assert_eq!(total, 1, "foo 大小写敏感仅命中 1 处");
|
||
assert_eq!(out.len(), 1, "命中 1 个文件");
|
||
assert!(out[0].file.ends_with("a.rs"));
|
||
assert_eq!(out[0].line_matches.len(), 1);
|
||
assert_eq!(out[0].line_matches[0].line, 1);
|
||
assert_eq!(out[0].line_matches[0].content, "fn foo() {}");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:正则匹配(锚定 ^)
|
||
#[tokio::test]
|
||
async fn test_grep_regex_match() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_regex_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
fs::write(tmp.join("a.rs"), "fn main() {}\nasync fn helper() {}\nfn main2() {}\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
// ^fn 匹配行首 fn
|
||
let re = regex::Regex::new("^fn ").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
// 第 1、3 行行首是 "fn ",第 2 行行首是 "async fn " 不命中 ^fn
|
||
assert_eq!(total, 2, "^fn 命中第 1、3 行");
|
||
assert_eq!(out[0].line_matches.len(), 2);
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:大小写不敏感(-i 语义,经 RegexBuilder case_insensitive)
|
||
#[tokio::test]
|
||
async fn test_grep_case_insensitive() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_ci_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
fs::write(tmp.join("a.rs"), "Foo\nfoo\nFOO\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::RegexBuilder::new("foo").case_insensitive(true).build().unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
assert_eq!(total, 3, "大小写不敏感命中 Foo/foo/FOO 三处");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:glob 过滤(仅搜 *.rs)
|
||
#[tokio::test]
|
||
async fn test_grep_glob_filter() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_glob_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// .rs 与 .ts 都含 "foo",glob *.rs 只搜 .rs
|
||
fs::write(tmp.join("a.rs"), "foo\n").unwrap();
|
||
fs::write(tmp.join("b.ts"), "foo\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let glob_re = Some(compile_glob_to_regex("*.rs").unwrap());
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, glob_re.as_ref(), "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
assert_eq!(out.len(), 1, "glob *.rs 仅命中 1 个文件");
|
||
assert!(out[0].file.ends_with("a.rs"), "应只命中 a.rs,b.ts 被过滤");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:噪音目录(.git/node_modules/target)与噪音文件(.bak)跳过
|
||
#[tokio::test]
|
||
async fn test_grep_skips_noise() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_noise_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(tmp.join(".git")).unwrap();
|
||
fs::write(tmp.join(".git").join("config"), "foo-in-git\n").unwrap();
|
||
fs::create_dir_all(tmp.join("target")).unwrap();
|
||
fs::write(tmp.join("target").join("app"), "foo-in-target\n").unwrap();
|
||
// 噪音文件 .bak
|
||
fs::write(tmp.join("a.rs.bak"), "foo-in-bak\n").unwrap();
|
||
// 正常文件
|
||
fs::write(tmp.join("a.rs"), "foo\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
// 只命中 a.rs,噪音目录(.git/target)与噪音文件(.bak)被跳过
|
||
assert_eq!(total, 1, "仅 a.rs 命中,噪音被跳过");
|
||
assert_eq!(out.len(), 1);
|
||
assert!(out[0].file.ends_with("a.rs"));
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:max_results 截断(total 累加全部,out 受限,truncated=true)
|
||
#[tokio::test]
|
||
async fn test_grep_truncation() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_trunc_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// 一个文件含 5 行 foo,max_results=2 截断
|
||
fs::write(tmp.join("a.rs"), "foo\nfoo\nfoo\nfoo\nfoo\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 0, 2, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
assert!(truncated, "达 max_results=2 应截断");
|
||
assert_eq!(total, 5, "total 累加全部 5 处命中");
|
||
// out 内行数受 max_results 限制(2 行)
|
||
let collected: usize = out.iter().map(|h| h.line_matches.len()).sum();
|
||
assert_eq!(collected, 2, "out 收集 2 行受 max_results 限制");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:context_lines 上下文(命中行前后各 N 行)
|
||
#[tokio::test]
|
||
async fn test_grep_context_lines() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_ctx_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// 第 3 行命中 foo,context_lines=1 应含第 2-4 行
|
||
fs::write(tmp.join("a.rs"), "line1\nline2\nfoo\nline4\nline5\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 1, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
assert_eq!(out[0].line_matches[0].line, 3);
|
||
// context = 第 2、3、4 行(命中行前 1 + 命中行 + 后 1)
|
||
assert_eq!(out[0].line_matches[0].context, "line2\nfoo\nline4");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:files_with_matches 模式仅返命中文件(不收集行详情)
|
||
#[tokio::test]
|
||
async fn test_grep_files_with_matches_mode() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_fwm_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
fs::write(tmp.join("a.rs"), "foo\nfoo\n").unwrap(); // 同文件多命中
|
||
fs::write(tmp.join("b.rs"), "foo\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "files_with_matches", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
// total 累加全部命中行(3),out 每文件 line_matches 空(不收集行详情)
|
||
assert_eq!(total, 3, "total 计全部命中行");
|
||
assert_eq!(out.len(), 2, "命中 2 个文件");
|
||
assert!(out.iter().all(|h| h.line_matches.is_empty()), "files_with_matches 不收集行详情");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// grep_recursive:二进制文件(\0)跳过
|
||
#[tokio::test]
|
||
async fn test_grep_skips_binary() {
|
||
let tmp = std::env::temp_dir().join(format!("df_grep_bin_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// 二进制文件(前 8KB 含 \0),内容含 "foo" 但应被跳过
|
||
let mut bin_content = b"foo\n".to_vec();
|
||
bin_content.push(0u8);
|
||
bin_content.extend_from_slice(b"more foo\n");
|
||
fs::write(tmp.join("a.bin"), &bin_content).unwrap();
|
||
// 正常文本文件
|
||
fs::write(tmp.join("a.rs"), "foo\n").unwrap();
|
||
let root = tmp.to_string_lossy().to_string();
|
||
|
||
let re = regex::Regex::new("foo").unwrap();
|
||
let mut out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total = 0usize;
|
||
let mut truncated = false;
|
||
grep_recursive(&root, &re, None, "content", 0, 50, 0, 6, &mut out, &mut total, &mut truncated).await.unwrap();
|
||
|
||
// 二进制 a.bin 被跳过,只命中 a.rs
|
||
assert_eq!(total, 1, "二进制文件被跳过,仅 a.rs 命中");
|
||
assert_eq!(out.len(), 1);
|
||
assert!(out[0].file.ends_with("a.rs"));
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// path_auth 未授权触发申请:grep 走单路径授权申请路径(非 search_files 盲拒)。
|
||
/// 完整 check_file_tool_auth → NeedsAuth 流程覆盖在 audit/mod.rs 测试模块,
|
||
/// 此处锁定 grep 编译正则 + glob 的预校验路径(pattern 空 bail、非法正则 bail)。
|
||
#[test]
|
||
fn test_grep_pattern_validation() {
|
||
// 非法正则编译失败上抛(锁定 grep handler 对非法 pattern 的明确错误,非 panic)
|
||
let bad = regex::RegexBuilder::new("[unclosed").build();
|
||
assert!(bad.is_err(), "未闭合 [ 应编译失败(锁定 handler map_err 路径)");
|
||
// 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
|
||
assert!(regex::Regex::new("").is_ok(), "regex 接受空串,handler 层显式 bail 空 pattern");
|
||
}
|
||
}
|