- patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明) - patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明 - tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压 - 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题 - prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议 - 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐
2703 lines
144 KiB
Rust
2703 lines
144 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;
|
||
use df_execute::shell::{execute, ShellRequest};
|
||
use df_storage::db::Database;
|
||
|
||
// ProjectServiceRecord/IdeaRecord/new_id/IdeaStatus 原 register_task_graph_tools/register_idea_tools 用
|
||
// (知识图谱/灵感工具已迁 tools/task_graph.rs、tools/idea.rs,本文件 lib 不再引用),
|
||
// 测试模块也无引用,故直接从 import 移除(非 #[cfg(test)] 保留)。
|
||
// RiskLevel 原 register_* 内联手写 register 用(已全迁声明式 tools/*,lib 不再引用)。
|
||
// ProjectRecord/ProjectStatus/now_millis 仅 #[cfg(test)] 用(项目工具已迁 tools/project.rs,
|
||
// lib 不再引用,迁移前同模块 import 会触发 unused warning,故移到 test-only 条件 import)。
|
||
#[cfg(test)]
|
||
use df_storage::models::ProjectRecord;
|
||
#[cfg(test)]
|
||
use df_types::types::ProjectStatus;
|
||
#[cfg(test)]
|
||
use crate::commands::now_millis;
|
||
|
||
use crate::state::AllowedDirs;
|
||
|
||
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
|
||
/// 用于 list_projects / list_tasks / list_ideas / list_trash
|
||
///
|
||
/// tool_registry 拆分第一步:list_projects 已迁声明式试点(tools/list_projects.rs),
|
||
/// 复用本常量保单真相源(避免字面量 50 漂移),故 pub(crate) 暴露给 tools 子模块。
|
||
pub(crate) const MAX_LIST_RESULTS: usize = 50;
|
||
|
||
/// run_command 默认超时(秒)。LLM 可在 args timeout_secs 覆盖此默认值。
|
||
/// 提取为常量便于在超时标注处引用同一来源。
|
||
///
|
||
/// tool_registry 拆分:run_command handler 迁声明式(tools/file.rs),复用本常量保单真相源
|
||
/// (避免字面量 60 漂移),故 pub(crate) 暴露给 tools 子模块。
|
||
pub(crate) const DEFAULT_RUN_COMMAND_TIMEOUT_SECS: u64 = 60;
|
||
/// run_command 超时上限(秒):防 LLM 传超大 timeout_secs 冻结会话(允许长构建,封顶 10min)
|
||
pub(crate) 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)。
|
||
///
|
||
/// 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"
|
||
}
|
||
|
||
/// 验证文件路径:禁止访问系统敏感目录 + 防路径遍历绕过
|
||
///
|
||
/// 安全要点
|
||
/// 1. **先 URL 解码**再检查——防 `%2e%2e` / `%2f` 等 URL 编码绕过 `..` 检测。
|
||
/// LLM / 外部输入可能传编码串,若直接做字符串 `..` 检查会被 `%2e%2e` 骗过,
|
||
/// 解码后再走分段归一化校验。
|
||
/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤
|
||
/// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别
|
||
/// 解码后 `..%2f` / `..\` 等变体。
|
||
/// 3. 敏感系统目录黑名单(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32),
|
||
/// 「信任所有目录」总开关(df-ai-path-auth-bypass)开启时放行。
|
||
pub(crate) 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 两套黑名单不一致)。
|
||
// 总开关开启时跳过(产品拍板:bypass 语义为完全放行,含敏感目录);标志由 audit 层每轮
|
||
// 工具执行前从同一 KV 刷新,与本函数无参签名解耦。.. 遍历检查保留在上:路径合法性校验,非权限边界。
|
||
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized))
|
||
&& !crate::commands::ai::audit::is_blacklist_bypassed()
|
||
{
|
||
anyhow::bail!("禁止访问敏感系统目录");
|
||
}
|
||
// 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)。
|
||
pub(crate) 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 三模式共用底层
|
||
//
|
||
// 三模式定位(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
|
||
// 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」,
|
||
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间
|
||
// (end 子串须唯一命中,多处命中报错)。多模式同时传入由 handler 层按优先级仲裁
|
||
// (见 tools/file.rs arbitrate_patch_mode)。提取为纯函数便于直接单测(不依赖 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,区间退化为单行替换。
|
||
/// - end 唯一性校验(治实测 ×2 多处命中替换错位):end 子串在 start 行(含)之后多处命中
|
||
/// 时返 Err,列出前两处命中行上下文,提示缩小 end 锚点或改用 old_text 模式。
|
||
/// 旧实现静默取第一个命中行,LLM 意图往往是更靠后的行,错位后返工。
|
||
/// - 返回 (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 all_lines: Vec<&str> = content.lines().collect();
|
||
let end_hits: Vec<usize> = all_lines
|
||
.iter()
|
||
.enumerate()
|
||
.filter(|(i, line)| i + 1 >= start_line && line.contains(end))
|
||
.map(|(i, _)| i + 1)
|
||
.collect();
|
||
let end_line = match end_hits.len() {
|
||
0 => anyhow::bail!(
|
||
"anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到",
|
||
end, start_line
|
||
),
|
||
1 => end_hits[0],
|
||
_ => {
|
||
// 列出前两处命中行原文(截断防超长行刷屏),供 LLM 对照改锚
|
||
let ctx = end_hits
|
||
.iter()
|
||
.take(2)
|
||
.map(|&ln| {
|
||
let raw = all_lines[ln - 1];
|
||
let shown: String = if raw.chars().count() > 80 {
|
||
raw.chars().take(80).collect::<String>() + "…"
|
||
} else {
|
||
raw.to_string()
|
||
};
|
||
format!("第 {} 行: {}", ln, shown)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join("\n");
|
||
anyhow::bail!(
|
||
"anchor.end 子串「{}」在 start 行(第 {} 行)之后命中 {} 处,定位歧义。前两处:\n{}\n请缩小 end 锚点(加长子串使其唯一)或改用 old_text 模式",
|
||
end, start_line, end_hits.len(), ctx
|
||
)
|
||
}
|
||
};
|
||
|
||
// 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 行),不需要全局文件队列。
|
||
pub(crate) 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 维度作弱保护。
|
||
pub(crate) 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())
|
||
}
|
||
|
||
/// 将原始字节解码为 UTF-8 字符串,优先识别 UTF-16 LE/BE BOM 解码转 UTF-8。
|
||
///
|
||
/// 背景(P1 修复):PowerShell `Out-File`/`>` 重定向默认产 UTF-16 LE BOM 文件,
|
||
/// 每个 ASCII 字符高字节为 0x00(NUL)。旧 read_file 用 read_to_string 直接读 →
|
||
/// (a) read_to_string 对含 NUL 字节内容硬失败 InvalidData;或 (b) 即便侥幸读到,
|
||
/// 后续 is_binary / patch_file 的 `\0` 检测把 UTF-16 文本误判二进制拒读 →
|
||
/// run_command(Out-File) → 文件 → read_file 链路在 Windows 断裂。
|
||
///
|
||
/// 本 helper 替代 read_to_string 的纯字节→字符串转换:
|
||
/// 1. UTF-16 LE BOM(FF FE):strip BOM 后 u16::from_le_bytes + String::from_utf16 解码;
|
||
/// 2. UTF-16 BE BOM(FE FF):同上 from_be_bytes;
|
||
/// 3. 无 BOM:走 String::from_utf8(等价原 read_to_string 的 UTF-8 校验)。
|
||
/// 注:UTF-8 BOM(EF BB BF)是合法 UTF-8 序列,read_to_string 原本就能读(内容首字符多 U+FEFF),
|
||
/// 不在本 helper 误判路径内,无需特判。
|
||
///
|
||
/// 解码失败(真二进制/非 UTF-8 非 BOM 内容)返 io::Error(InvalidData),与 read_to_string
|
||
/// 同语义——调用方保留原 `if e.kind() == InvalidData { 返 binary 标记 }` 分支不变,
|
||
/// 真二进制(图片/编译产物)仍被拦截,UTF-16 文本因 BOM 优先识别不再进 \x00 误判路径。
|
||
///
|
||
/// 奇数字节 UTF-16(尾字节落单):drop 最后一个残字节后解码(对齐 PowerShell 偶发写半字符场景,
|
||
/// 不 bail 让多数可读内容通过)。Lone surrogate 交 String::from_utf16 lossy 兜底不 panic。
|
||
pub(crate) fn decode_bytes_to_string(bytes: &[u8]) -> std::io::Result<String> {
|
||
// UTF-16 LE BOM: FF FE
|
||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||
let body = &bytes[2..];
|
||
let units: Vec<u16> = body
|
||
.chunks_exact(2)
|
||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||
.collect();
|
||
// from_utf16 遇 lone surrogate 返 Err(此处用 lossy 兜底保内容可读,不阻断)
|
||
return Ok(String::from_utf16_lossy(&units));
|
||
}
|
||
// UTF-16 BE BOM: FE FF
|
||
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
|
||
let body = &bytes[2..];
|
||
let units: Vec<u16> = body
|
||
.chunks_exact(2)
|
||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||
.collect();
|
||
return Ok(String::from_utf16_lossy(&units));
|
||
}
|
||
// 无 BOM:走 UTF-8 校验(等价 read_to_string,失败 InvalidData → 调用方判二进制)
|
||
String::from_utf8(bytes.to_vec())
|
||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "非 UTF-8 / 非 UTF-16 BOM 文本"))
|
||
}
|
||
|
||
/// 阶段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 兜底)。
|
||
pub(crate) 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)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 解析文件工具路径:相对路径锚定已授权目录的首个 persistent 目录,禁止越出项目目录。
|
||
///
|
||
/// 双层校验:
|
||
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
|
||
/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
|
||
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
|
||
///
|
||
/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
|
||
/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
|
||
pub(crate) fn resolve_workspace_path_with_allowed(
|
||
path: &str,
|
||
allowed: &AllowedDirs,
|
||
) -> anyhow::Result<PathBuf> {
|
||
resolve_workspace_path_impl(path, allowed)
|
||
}
|
||
|
||
/// mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
|
||
/// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数
|
||
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
|
||
///
|
||
/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
|
||
/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
|
||
/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响。
|
||
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
|
||
resolve_workspace_path_impl(path, &AllowedDirs::default())
|
||
}
|
||
|
||
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
|
||
validate_path(path)?;
|
||
let resolved = if Path::new(path).is_absolute() {
|
||
PathBuf::from(path)
|
||
} else {
|
||
// 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
|
||
// 无授权目录时给出明确引导,避免指向编译期常量。
|
||
let root = allowed.first_persistent_dir()
|
||
.ok_or_else(|| anyhow::anyhow!(
|
||
"请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
|
||
))?;
|
||
root.join(path)
|
||
};
|
||
// 词法层:防明显越界(不存在路径的兜底)。
|
||
if !allowed.is_authorized(&resolved) {
|
||
anyhow::bail!("路径不在授权目录内: {}", path);
|
||
}
|
||
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
|
||
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 返回结构)
|
||
pub(crate) 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 工具不涉及文件路径,不传白名单。
|
||
///
|
||
/// get_app_config(2026-08-02):新增 `get_app_config_ctx: GetAppConfigCtx` 形参,
|
||
/// 该工具查 DevFlow 自身当前 AI 配置(provider/agent/settings),需读 AppState 内存原子量
|
||
/// (agent_max_iterations 等)+ db。因本函数在 AppState::init 内、AppState 构建中被调,
|
||
/// 无法传 &AppState(自引用),故把所需 Arc 句柄打包成 GetAppConfigCtx 传入(state.rs
|
||
/// init 先建这些 Arc 再 build registry 再组装 state,顺序调整)。
|
||
///
|
||
/// local_proxy(2026-08-06):新增 `local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx` 形参,
|
||
/// 该工具集操作本地 4 个工具代理(mysql/ssh/mongo/redis),经 DevFlow 内嵌统一网关
|
||
/// (gateway_url 默认 http://127.0.0.1:8899,DF_LOCAL_PROXY_BASE 可覆盖),无 SSRF 面故直接
|
||
/// reqwest,不持 db/路径。声明式注册在 tools/local_proxy.rs(11 个工具)。
|
||
pub fn build_ai_tool_registry(
|
||
db: &Arc<Database>,
|
||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||
data_dir: PathBuf,
|
||
get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
|
||
local_proxy_ctx: super::tools::local_proxy::LocalProxyCtx,
|
||
spawn_agent_ctx: super::tools::spawn_agent::SpawnAgentCtx,
|
||
) -> AiToolRegistry {
|
||
let mut registry = AiToolRegistry::new();
|
||
register_data_tools(&mut registry, db);
|
||
register_file_tools(&mut registry, allowed_dirs, data_dir);
|
||
register_http_tools(&mut registry, allowed_dirs);
|
||
register_fetch_url_tool(&mut registry);
|
||
register_fetch_search_tool(&mut registry);
|
||
register_generate_image_tool(&mut registry, db, allowed_dirs);
|
||
register_get_app_config_tool(&mut registry, get_app_config_ctx);
|
||
register_local_proxy_tools(&mut registry, local_proxy_ctx);
|
||
register_read_skill_tool(&mut registry);
|
||
register_spawn_agent_tool(&mut registry, spawn_agent_ctx);
|
||
registry
|
||
}
|
||
|
||
/// spawn_agent AI 工具注册(1 个:派发子代理,隔离上下文执行专项任务)。
|
||
///
|
||
/// 工具职责:LLM 调 spawn_agent(task, tools?, max_rounds?, model?) 派发子代理,
|
||
/// 子代理在纯内存子会话跑独立 agentic 循环(agentic/sub_agent.rs),报告经 tool_result
|
||
/// 返回值交付。风险 Medium(派发需用户审批,与 create_task 同档)。
|
||
///
|
||
/// ctx 绕 AppState 自引用(同 GetAppConfigCtx 先例):session/conv_states/llm_concurrency/
|
||
/// db/sub_agents 全 Arc 字段,由 state.rs init 在 build registry 前提前构建注入,
|
||
/// AppState struct 字段复用同一 Arc。工具注册表自身经进程级 OnceLock
|
||
/// (init_global_tool_registry)在 state.rs init 构建完成后回注,供白名单钳制查询。
|
||
fn register_spawn_agent_tool(
|
||
registry: &mut AiToolRegistry,
|
||
ctx: super::tools::spawn_agent::SpawnAgentCtx,
|
||
) {
|
||
super::tools::spawn_agent::register(registry, ctx);
|
||
}
|
||
|
||
/// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。
|
||
///
|
||
/// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文,
|
||
/// 与 / 技能注入链共用 skills.rs 进程内缓存(同一真相源)。未命中返回明确错误文案。
|
||
/// 风险:Low(纯只读)。声明式注册在 tools/read_skill.rs。
|
||
fn register_read_skill_tool(registry: &mut AiToolRegistry) {
|
||
super::tools::read_skill::register(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 两工具。
|
||
///
|
||
/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原
|
||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||
///
|
||
/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给
|
||
/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。
|
||
fn register_http_tools(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<AllowedDirs>>) {
|
||
super::tools::http::register(registry, allowed_dirs);
|
||
}
|
||
|
||
/// fetch_url AI 工具注册(1 个:URL → markdown 文档嗅探)— 只读 GET 网页文档。
|
||
/// 不持 db,纯 reqwest GET + htmd HTML→markdown 转换。SSRF 防护复用 commands/ai/http.rs
|
||
/// (validate_url / resolve_and_check_host / execute_with_redirects,经 pub(crate) 暴露)。
|
||
///
|
||
/// 风险:Low(只读 GET,无副作用)。与 http_request 分工:fetch_url 输出已清洗 markdown 供 LLM 理解文档,
|
||
/// http_request 输出原始响应 body 供 API 调用(POST/鉴权)。
|
||
///
|
||
/// tool_registry 拆分:声明式注册(tools/fetch_url.rs),handler 在 commands/ai/fetch_url.rs。
|
||
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 守护总量 + 工具名集合稳定。
|
||
fn register_fetch_url_tool(registry: &mut AiToolRegistry) {
|
||
super::tools::fetch_url::register(registry);
|
||
}
|
||
|
||
/// fetch_search AI 工具注册(1 个:搜索引擎查询)— DuckDuckGo HTML 端点免 key 搜索。
|
||
/// 不持 db,纯 reqwest GET + HTML 字符串扫描解析。SSRF 防护复用 commands/ai/http.rs
|
||
/// (validate_url / resolve_and_check_host / build_client / execute_with_redirects)。
|
||
///
|
||
/// 风险:Low(只读 GET 搜索,无副作用,与 fetch_url 同级)。与 fetch_url 分工:fetch_url 输出
|
||
/// 已知 URL 的 markdown 正文,fetch_search 输出未知关键词的候选 URL 列表(标题+摘要),
|
||
/// 供 LLM 挑选入口再 fetch_url 深读。补「fetch_url 只抓已知 URL,无搜索能力」缺口。
|
||
///
|
||
/// HTML 解析:手写字符串扫描(对齐 fetch_url::extract_title 风格),不引 scraper 重依赖
|
||
/// (html5ever+selectors+cssparser 编译产物大,DDG 单一来源不值)。基线测试守护总量 + 工具名集合。
|
||
fn register_fetch_search_tool(registry: &mut AiToolRegistry) {
|
||
super::tools::fetch_search::register(registry);
|
||
}
|
||
|
||
/// generate_image AI 工具注册(1 个:调 provider 图像生成端点 + 下载落地)。
|
||
///
|
||
/// 工具职责:调 provider 的 OpenAI 兼容 `/v1/images/generations` 端点生成图片(默认 SenseNova
|
||
/// U1 Fast,也支持 DALL-E 等),自动下载到工作区 output_path。图像生成模型非 chat,走独立
|
||
/// 端点非 chat completions,故不能经主对话链路调用,必须用本工具显式触发。
|
||
///
|
||
/// 涉网络(provider POST + 图片 URL 下载)+ 付费 API(按张计费)+ 写文件(落盘 output_path),
|
||
/// 与 download_file 同源(均持 allowed_dirs 做 output_path 白名单校验 + 默认路径锚定)。
|
||
/// risk=High(付费 + 写磁盘 + 外发)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||
/// 守护总量 + 工具名集合稳定。
|
||
///
|
||
/// handler 在 commands/ai/generate_image.rs,声明式注册在 tools/generate_image.rs。
|
||
/// declare_tool! 单捕获限制:tools 层把 (db, allowed_dirs) 包进 tuple Arc 在闭包内解构。
|
||
fn register_generate_image_tool(
|
||
registry: &mut AiToolRegistry,
|
||
db: &Arc<Database>,
|
||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||
) {
|
||
super::tools::generate_image::register(registry, db, allowed_dirs);
|
||
}
|
||
|
||
/// get_app_config AI 工具注册(1 个:查 DevFlow 自身当前 AI 配置,只读 + 脱敏)—
|
||
/// 返 {default_provider, providers, agent, app_settings} JSON。
|
||
///
|
||
/// 治症状:AI 想看「当前 provider/model/agent 设置」时,因没原生查配置工具,只能 run_command
|
||
/// 执行 PowerShell 内联 node/python 脚本查 db,引号三重嵌套必失败。本工具一调即得,根治绕行。
|
||
///
|
||
/// 风险:Low(纯只读 list/load,无写副作用)。安全:api_key 经 mask_api_key 脱敏
|
||
/// (前 4 + •••• + 后 4),绝不返明文;app_settings 走白名单不返敏感 KV。
|
||
///
|
||
/// handler 在 commands/ai/get_app_config.rs,声明式注册在 tools/get_app_config.rs。
|
||
/// declare_tool! 单捕获:把 db + 3 个 agent 配置 Arc + LlmConcurrency 包进 GetAppConfigCtx
|
||
/// struct(Clone 廉价,全 Arc 字段),闭包内 &ctx 转调 handler。
|
||
///
|
||
/// ctx 字段与 AppState 同名字段共享同一底层原子量(经 ai_set_concurrency_config /
|
||
/// ai_set_agent_max_iterations 等 IPC 热改后,handler 读到的永远是当前生效值)。
|
||
fn register_get_app_config_tool(
|
||
registry: &mut AiToolRegistry,
|
||
ctx: super::get_app_config::GetAppConfigCtx,
|
||
) {
|
||
super::tools::get_app_config::register(registry, ctx);
|
||
}
|
||
|
||
/// local_proxy 工具代理注册(11 个:mysql/ssh/mongo/redis 本地代理)— thin 委托。
|
||
///
|
||
/// 经 DevFlow 内嵌统一网关(local_proxy_gateway:127.0.0.1:8899)路由到本地 4 个 HTTP 代理
|
||
/// (mysql→3307 / ssh→3308 / mongo→3309 / redis→3310),固定 gateway_url 无 SSRF 面,
|
||
/// 声明式注册在 tools/local_proxy.rs。
|
||
///
|
||
/// risk 口径对齐 http_request:只读查询(Low)/ 数据写入(High)/ 轻量写(Medium)。
|
||
/// declare_tool! 单捕获:仅需 gateway_url,包进 LocalProxyCtx struct(Clone 廉价)。
|
||
fn register_local_proxy_tools(
|
||
registry: &mut AiToolRegistry,
|
||
ctx: super::tools::local_proxy::LocalProxyCtx,
|
||
) {
|
||
super::tools::local_proxy::register(registry, ctx);
|
||
}
|
||
|
||
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
|
||
///
|
||
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
|
||
/// 例外:run_workflow handler 防御返回 Err,不持 db 不 clone,真正执行经
|
||
/// ai_approve → run_workflow_inner 另走完整 State 路径。
|
||
///
|
||
/// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD
|
||
/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。
|
||
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流查询),
|
||
/// data 层 24→25。
|
||
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
|
||
/// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。
|
||
///
|
||
/// 抽自原 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
|
||
/// 知识图谱 Phase 1(2026-06-26)新增第 6 子函数:
|
||
/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新
|
||
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流,只读),
|
||
/// 该子函数 6→7 工具,data 层 24→25。
|
||
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
|
||
/// list_project_services(Low 只读) 项目基础设施配置(D10 不存凭证),该子函数 7→9 工具,data 层 25→27。
|
||
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增,
|
||
/// 行为零变更(create_task 扩展参数 + 7 新工具,基线测试 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_task_graph_tools(registry, db);
|
||
register_workflow_tools(registry);
|
||
register_idea_tools(registry, db);
|
||
register_knowledge_tools(registry, db);
|
||
register_trash_tools(registry, db);
|
||
register_git_tools(registry, db);
|
||
}
|
||
|
||
/// 项目类 AI 工具注册(8 个:CRUD + 目录绑定 + 探总量)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
/// 组内顺序保留原相对顺序(list/update/create/bind/delete/restore/purge/get_count)。
|
||
///
|
||
/// tool_registry 拆分:8 个项目工具全部迁声明式(tools/list_projects.rs 试点 +
|
||
/// tools/project.rs 剩余 7 个),handler body 逐字照搬原 async move 块(逻辑零变更),
|
||
/// 仅闭包包装改由 `declare_tool!` 宏生成。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::list_projects::register(registry, db);
|
||
super::tools::project::register(registry, db);
|
||
}
|
||
|
||
/// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
/// 组内顺序保留原相对顺序(list/create/update/advance/delete/get_count)。
|
||
///
|
||
/// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs),
|
||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||
/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::task::register(registry, db);
|
||
}
|
||
|
||
/// 知识图谱 / 工程系统类 AI 工具注册(10 个:task_link CRUD + 父子树 + 跨池移动 + content 更新
|
||
/// + 项目事件流查询 + 项目基础设施配置 + 工程列表) —— 持 db:Arc<Database>,对标设计 §五
|
||
/// Phase1/Phase2/Phase3 AI 工具表 + §2.1/§2.2/§2.3/§2.4 + 工程系统。
|
||
///
|
||
/// 工具直接调 df_storage::crud Repo(与 register_task_tools 同源模式,经 db Arc 重建 Repo),
|
||
/// 复用 IPC 层(commands::task)的底层 Repo 方法,不重复实现业务校验:
|
||
/// - link CRUD / 反查:TaskLinkRepo(create_link 内含 link_type 白名单 + depends_on BFS 环检测)。
|
||
/// - 父子树:get_children(限 1 级嵌套,设计 D2)。
|
||
/// - 跨池移动:queue/status 一致性联动(对齐 commands::task::move_task_queue 语义)。
|
||
/// - content 更新:走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验。
|
||
///
|
||
/// 风险等级对标设计 §五:link 写=Medium / 只读=Low / queue 移动=Medium / content 更新=Medium。
|
||
///
|
||
/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs),
|
||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||
/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试
|
||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::task_graph::register(registry, db);
|
||
}
|
||
|
||
/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
|
||
///
|
||
/// AI 据此自主查看工程代码仓库状态(status/diff/log 无需审批),commit/branch/merge 需审批。
|
||
/// 均在工程目录(module.path)执行,无 .git 返回空状态。
|
||
///
|
||
/// tool_registry 拆分:6 个 git 工具全部迁声明式(tools/git.rs),配套私有 helper
|
||
/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)。
|
||
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
|
||
/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试
|
||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::git::register(registry, db);
|
||
}
|
||
|
||
/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
|
||
/// 真正执行经 ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner。
|
||
///
|
||
/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原
|
||
/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。
|
||
/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试
|
||
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_workflow_tools(registry: &mut AiToolRegistry) {
|
||
super::tools::workflow::register(registry);
|
||
}
|
||
|
||
/// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
///
|
||
/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原
|
||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||
/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::idea::register(registry, db);
|
||
}
|
||
|
||
/// 知识库类 AI 工具注册(3 个:knowledge_search 检索 + knowledge_insert 沉淀 +
|
||
/// knowledge_promote 显式转正)——持 db:Arc<Database>。
|
||
///
|
||
/// 打通 aichat 对话侧与知识库:此前 KnowledgeRepo 的检索/写入仅有 IPC 命令与 MCP 入口,
|
||
/// LLM 工具注册表无对应工具,agent 对话中既查不了过往经验也无法沉淀要点(知识库与记忆
|
||
/// 走查 2026-09-29 缺口 4)。insert 固定写 status='candidate'(LLM 写入不自动发布,
|
||
/// 与自动提炼同信任级),promote 补齐对话内显式转正通道(candidate→published 单向,
|
||
/// 与默认关闭的自动晋升互不影响),handler 细节见 tools/knowledge.rs。
|
||
///
|
||
/// 风险:search/insert 为 Low(检索只读;插入为本地库追加写,无覆盖无删除,落候选不进
|
||
/// 检索面);promote 为 Medium(改既有条目状态进检索面,单向候选转正可 UI 归档修正)。
|
||
fn register_knowledge_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::knowledge::register(registry, db);
|
||
}
|
||
|
||
/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
|
||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||
///
|
||
/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原
|
||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||
/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||
super::tools::trash::register(registry, db);
|
||
}
|
||
|
||
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
|
||
/// 从 build_ai_tool_registry 抽出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 配置时行为不变)。
|
||
///
|
||
/// tool_registry 拆分:13 个文件工具全部迁声明式(tools/file.rs),handler body 逐字照搬原
|
||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||
/// super::tools::file::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||
fn register_file_tools(
|
||
registry: &mut AiToolRegistry,
|
||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||
data_dir: PathBuf,
|
||
) {
|
||
super::tools::file::register(registry, allowed_dirs, data_dir);
|
||
// download_file:跨平台 URL→文件流式下载(reqwest bytes_stream + tokio::fs)。
|
||
// 写文件需路径授权(与 write_file 同源),故在 register_file_tools 注册(已有 allowed_dirs)。
|
||
// SSRF 复用 commands/ai/http.rs,大小上限 200MB + 原子写(tmp→rename)。
|
||
super::tools::download_file::register(registry, allowed_dirs);
|
||
}
|
||
|
||
/// 探测可执行文件路径(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)。
|
||
pub(crate) 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)` 表示被截断
|
||
pub(crate) 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_entry(&name, NoiseDirTier::AiContext) {
|
||
continue;
|
||
}
|
||
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
|
||
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文。
|
||
if skip_noise && is_noise_file(&name) {
|
||
continue;
|
||
}
|
||
// 用 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_entry(&name, NoiseDirTier::AiContext)) {
|
||
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 到通用风险提示。
|
||
///
|
||
/// DRY 债 #5 收敛:模板不再本文件手写 match,改查注册元数据(approval_hint 维度),
|
||
/// 模板在 declare_tool! 注册点(tools/project.rs 等)声明。
|
||
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||
ensure_tool_meta_seeded();
|
||
df_ai::ai_tools::approval_hint(name)
|
||
}
|
||
|
||
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
|
||
///
|
||
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
|
||
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
|
||
///
|
||
/// DRY 债 #5 收敛:标签不再本文件手写 match,改查注册元数据(display_verb 维度)。
|
||
/// advance_task 刻意无标签:轻量命中会丢 target_status 详情,审批文案须走模板,与旧表一致。
|
||
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
|
||
ensure_tool_meta_seeded();
|
||
df_ai::ai_tools::display_verb(name)
|
||
}
|
||
|
||
/// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。
|
||
///
|
||
/// 生产 build_ai_tool_registry 先于任何查询执行,表必非空,本函数恒空操作;
|
||
/// 单测可能不经 build 直接调消费方自由函数(extract_file_tool_paths/tool_call_signature 等),
|
||
/// 表尚空时用 in-memory db 构建一次真实注册表,让 declare_tool! 注册点把元数据写入全局表。
|
||
/// Once 保证并发测试只种一次;表已非空(任一测试已 build)则跳过。
|
||
#[cfg(test)]
|
||
pub(crate) fn ensure_tool_meta_seeded() {
|
||
static SEED: std::sync::Once = std::sync::Once::new();
|
||
SEED.call_once(|| {
|
||
if !df_ai::ai_tools::meta_table_is_empty() {
|
||
return;
|
||
}
|
||
let rt = tokio::runtime::Runtime::new().expect("元数据种子 runtime");
|
||
rt.block_on(async {
|
||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||
let _ = build_ai_tool_registry(
|
||
&db,
|
||
&allowed_dirs,
|
||
PathBuf::from(""),
|
||
make_test_get_app_config_ctx(&db),
|
||
make_test_local_proxy_ctx(),
|
||
make_test_spawn_agent_ctx(&db),
|
||
);
|
||
});
|
||
});
|
||
}
|
||
|
||
/// 生产恒空(注册先于查询,无种子需求)。
|
||
#[cfg(not(test))]
|
||
pub(crate) fn ensure_tool_meta_seeded() {}
|
||
|
||
/// 噪音目录判定档位:不同消费面"哪些目录算噪音"的口径不同,差异用档位在此显式表达,
|
||
/// 各消费方不再自持名单(DRY #4:全仓唯一名单入口)。
|
||
pub(crate) enum NoiseDirTier {
|
||
/// AI 工具上下文(list_directory/grep 递归):全量名单,构建产物/依赖/缓存/IDE 元数据/回收站不进上下文
|
||
AiContext,
|
||
/// 前端文件树/工程扫描:全量名单放行 `build` 与 `venv`(文件树面向用户浏览要见构建产物;
|
||
/// venv 历史上未过滤,行为保持)。点开头目录(.git/.vite 等)由调用方的隐藏文件过滤兜住
|
||
FileTree,
|
||
/// 技能扫描递归剪枝:最小集(仅依赖/VCS)。扩大名单属行为变更(build/ 下若有 SKILL.md 会漏扫)
|
||
SkillScan,
|
||
}
|
||
|
||
/// 判断是否为不应深入递归/展示的噪音条目(目录/文件名皆可传入,构建产物/依赖/缓存等)。
|
||
/// 全仓唯一名单入口。命中判定短路返回,技能扫描等热路径可放心逐条目调用。
|
||
pub(crate) fn is_noise_entry(name: &str, tier: NoiseDirTier) -> bool {
|
||
const FULL: &[&str] = &[
|
||
".git", "node_modules", "target", "dist", "build",
|
||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||
".trash", // delete_file 软删除回收站,不污染 list_directory
|
||
];
|
||
match tier {
|
||
NoiseDirTier::AiContext => FULL.contains(&name),
|
||
// 文件树口径 = 全量名单放行 build(用户要看构建产物)与 venv(历史未过滤,行为保持)
|
||
NoiseDirTier::FileTree => FULL.contains(&name) && !matches!(name, "build" | "venv"),
|
||
// 技能扫描最小集:只防依赖/VCS 递归爆炸,与历史两项一致
|
||
NoiseDirTier::SkillScan => matches!(name, "node_modules" | ".git"),
|
||
}
|
||
}
|
||
|
||
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
|
||
///
|
||
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
|
||
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
|
||
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
|
||
pub(crate) 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 工具底层
|
||
//
|
||
// 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 行号 + 行内容 + 上下文)
|
||
pub(crate) struct LineMatch {
|
||
pub line: usize,
|
||
pub content: String,
|
||
pub context: String,
|
||
}
|
||
|
||
/// 单文件命中聚合(file=绝对/锚定路径,line_matches 按行号升序)
|
||
pub(crate) struct FileGrepHit {
|
||
pub file: String,
|
||
pub 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 友好提示。
|
||
pub(crate) 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_entry)/噪音文件(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` 由调用方读后构造响应。
|
||
pub(crate) 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_entry(&file_name, NoiseDirTier::AiContext) {
|
||
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;
|
||
}
|
||
// 文件处理提取为 grep_one_file,grep handler 单文件路径复用同一逻辑)
|
||
grep_one_file(&path, &file_name, re, glob, output_mode, context_lines, max_results, out, total, truncated).await?;
|
||
}
|
||
Ok(())
|
||
})
|
||
}
|
||
|
||
/// 单文件 grep:读 + 二进制检测 + 行匹配,收集命中到 out。
|
||
/// 提取自 grep_recursive 循环体,供 grep handler 单文件路径复用——
|
||
/// grep 命令行原生支持单文件,LLM 常传文件路径而非目录,旧实现直接 read_dir 文件路径报 os error 267。
|
||
/// files_with_matches:文件级命中(content/count 不收集行详情);content/count:行级命中,达 max_results 截断。
|
||
pub(crate) async fn grep_one_file(
|
||
path: &std::path::Path,
|
||
file_name: &str,
|
||
re: ®ex::Regex,
|
||
glob: Option<®ex::Regex>,
|
||
output_mode: &str,
|
||
context_lines: usize,
|
||
max_results: usize,
|
||
out: &mut Vec<FileGrepHit>,
|
||
total: &mut usize,
|
||
truncated: &mut bool,
|
||
) -> anyhow::Result<()> {
|
||
// 噪音文件(.bak/.tmp-write 等编辑器产物)跳过
|
||
if is_noise_file(file_name) {
|
||
return Ok(());
|
||
}
|
||
// glob 过滤(单段匹配文件名,glob_re 已锚定 ^...$ 整段匹配)
|
||
if let Some(g) = glob {
|
||
if !g.is_match(file_name) {
|
||
return Ok(());
|
||
}
|
||
}
|
||
// 单文件读取:1MB 上限 + 二进制检测(\0)。读失败静默跳过(权限/竞态,不阻断整体搜索)
|
||
let metadata = match tokio::fs::metadata(path).await {
|
||
Ok(m) => m,
|
||
Err(_) => return Ok(()),
|
||
};
|
||
if metadata.len() > 1_048_576 {
|
||
return Ok(()); // >1MB 跳过(对齐 read_file 上限)
|
||
}
|
||
let bytes = match tokio::fs::read(path).await {
|
||
Ok(b) => b,
|
||
Err(_) => return Ok(()),
|
||
};
|
||
// 二进制检测:前 8KB 含 \0 视为二进制跳过(对齐 file_info/read_file)
|
||
if bytes.iter().take(8192).any(|&b| b == 0) {
|
||
return Ok(());
|
||
}
|
||
// 转 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 {
|
||
return Ok(()); // 本文件无命中
|
||
}
|
||
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 不收集行详情
|
||
});
|
||
}
|
||
return Ok(());
|
||
}
|
||
// 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(())
|
||
}
|
||
|
||
/// 递归搜索文件(字符串包含匹配,大小写不敏感)
|
||
pub(crate) 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(())
|
||
})
|
||
}
|
||
|
||
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
|
||
// 仅用于 build_ai_tool_registry 注册阶段,handler 不实际执行故无需真实配置)。
|
||
// 提到 tests 模块外(file 级 cfg(test)):ensure_tool_meta_seeded 种子路径也要用。
|
||
#[cfg(test)]
|
||
use std::sync::atomic::{AtomicU64, AtomicUsize};
|
||
#[cfg(test)]
|
||
use crate::state::LlmConcurrency;
|
||
|
||
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
|
||
///
|
||
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||
#[cfg(test)]
|
||
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||
crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||
db: db.clone(),
|
||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
|
||
)),
|
||
agent_max_retries: Arc::new(AtomicUsize::new(
|
||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
|
||
)),
|
||
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
|
||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||
}
|
||
}
|
||
|
||
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
|
||
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
|
||
#[cfg(test)]
|
||
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||
gateway_url: "http://127.0.0.1:8899".to_string(),
|
||
}
|
||
}
|
||
|
||
/// build_ai_tool_registry 第 6 参(spawn_agent 派发 ctx),测试调用共用。
|
||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||
#[cfg(test)]
|
||
fn make_test_spawn_agent_ctx(db: &Arc<Database>) -> crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
|
||
crate::commands::ai::tools::spawn_agent::SpawnAgentCtx {
|
||
session: Arc::new(tokio::sync::Mutex::new(crate::commands::ai::AiSession::new())),
|
||
conv_states: Arc::new(
|
||
crate::commands::ai::agentic::conv_state::ConvStateStore::new(),
|
||
),
|
||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||
db: db.clone(),
|
||
sub_agents: crate::commands::ai::agentic::sub_agent::new_registry(),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::fs;
|
||
|
||
// ============================================================
|
||
// 工具注册基线测试(SMELL-P0-2 拆分防护)
|
||
//
|
||
// 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。
|
||
// build_ai_tool_registry 经两层 register_* 组装:data(25 持 db) + file(13 不持 db) + http(1) = 39。
|
||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||
// ============================================================
|
||
|
||
/// build_ai_tool_registry 应注册恰好 78 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent),且工具名集合稳定。
|
||
///
|
||
/// 用 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, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
|
||
|
||
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
|
||
// file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
||
// AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
|
||
// 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具:
|
||
// create_task_link/remove_task_link/list_task_links/get_task_tree/move_task_queue/update_content)。
|
||
// 知识图谱 Phase 2(2026-06-26): data 层 24→25(新增 get_project_timeline 项目事件流查询,
|
||
// register_task_graph_tools 6→7)。
|
||
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
|
||
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
||
// 工程系统(2026-06-29): data 层 27→28(新增 list_project_modules 工程列表查询,
|
||
// register_task_graph_tools 9→10)
|
||
// Git 只读工具(2026-06-29): data 层 28→31(新增 git_status/git_diff/git_log,
|
||
// register_git_tools 3 个)
|
||
// Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge,
|
||
// register_git_tools 3→6)
|
||
// 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。
|
||
// 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。
|
||
// update_idea/delete_idea(2026-08-01): data 层 34→36(补 AI 改/删灵感专用工具,
|
||
// idea_repo.update_field / soft_delete 早已具备,工具层此前缺失致 AI 误用 update_task)。
|
||
// 50 = 36 data + 13 file + 1 http。
|
||
// fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET,
|
||
// htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。
|
||
// 51 = 36 data + 13 file + 1 http + 1 fetch_url。
|
||
// download_file(2026-08-01): file 层 13→14(新增 URL→文件跨平台流式下载,
|
||
// reqwest bytes_stream + tokio::fs,替代 curl/wget alias 陷阱。写文件需 allowed_dirs
|
||
// 故注册在 register_file_tools;SSRF 复用 http.rs + 路径校验复用 validate_path)。
|
||
// 52 = 36 data + 14 file + 1 http + 1 fetch_url。
|
||
// fetch_search(2026-08-02): fetch_search 层 1→2(新增搜索引擎查询,DuckDuckGo HTML 免 key,
|
||
// GET + HTML 字符串扫描解析结果列表,补 fetch_url 只抓已知 URL 缺口。SSRF 复用 http.rs)。
|
||
// 53 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search。
|
||
// generate_image(2026-08-02): generate_image 层 1→1(新增调 provider 图像生成端点 +
|
||
// 下载落地,默认 sensenova-u1-fast,也支持 DALL-E。SSRF 复用 http.rs + 路径校验复用
|
||
// resolve_workspace_path_with_allowed,declare_tool! 单捕获绕法用 tuple Arc)。
|
||
// 54 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image。
|
||
// get_app_config(2026-08-02): get_app_config 层 1→1(新增查 DevFlow 自身当前 AI 配置,
|
||
// 只读 + api_key 脱敏,治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败。
|
||
// build_ai_tool_registry 加第 4 参 GetAppConfigCtx 绕 AppState 自引用,declare_tool!
|
||
// 单捕获用 struct ctx 包 db + 3 agent Arc + LlmConcurrency,共享 AppState 同名句柄)。
|
||
// 55 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config。
|
||
// local_proxy(2026-08-06): local_proxy 层 0→11(新增 mysql/ssh/mongo/redis 本地工具代理
|
||
// 集成:list_proxy_connections/mysql_query/mysql_execute/ssh_exec/mongo_find/mongo_insert/
|
||
// mongo_update/mongo_delete/redis_get/redis_set/redis_del,声明式注册在 tools/local_proxy.rs,
|
||
// build_ai_tool_registry 加第 5 参 LocalProxyCtx)。
|
||
// 66 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||
// 工程模块写工具(2026-08-08): data 层 36→42(新增 create_module/update_module/delete_module +
|
||
// list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs,
|
||
// 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。
|
||
// 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||
// diff_files(2026-08-08): file 层 14→15(新增任意两文件内容对比,只读 Low,
|
||
// 补 git_diff 仅仓库内缺口;复用 generate_diff LCS unified diff;namespace ALWAYS_LARGE_TOOLS 既有引用生效)。
|
||
// 73 = 42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||
// 知识库工具(2026-09-29): data 层 42→44(新增 knowledge_search/knowledge_insert,
|
||
// 打通对话侧与知识库双向检索/沉淀;insert 落 candidate 不自动发布,均为 Low)。
|
||
// 75 = 44 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
|
||
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。
|
||
// 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||
// read_skill(2026-10-01): read_skill 层 0→1(按名读本机 Claude 技能规程正文,只读 Low,
|
||
// 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。
|
||
// 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。
|
||
// 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。
|
||
// spawn_agent(2026-10-01): spawn_agent 层 0→1(派发子代理隔离上下文执行专项任务,
|
||
// Medium 审批档;独立 agentic 循环在 agentic/sub_agent.rs,报告经返回值交付。
|
||
// SpawnAgentCtx 绕 AppState 自引用同 GetAppConfigCtx 先例,注册表自引用走进程级 OnceLock)。
|
||
// 78 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent。
|
||
assert_eq!(
|
||
registry.len(),
|
||
78,
|
||
"工具总数应为 78(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill + 1 spawn_agent),实际 {}", registry.len()
|
||
);
|
||
|
||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||
// data 层 45 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具 + 知识库检索/沉淀/转正
|
||
// file 层 15 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载/对比
|
||
// http 层 1 个(不持 db):http_request
|
||
let mut expected: Vec<&str> = vec![
|
||
// ── data 层 (44) ──
|
||
"list_projects", "list_tasks", "list_ideas",
|
||
"update_project", "create_project", "bind_directory",
|
||
"create_task", "update_task", "advance_task",
|
||
"run_workflow", "delete_task", "create_idea",
|
||
"update_idea", "delete_idea",
|
||
"delete_project", "restore_project", "purge_project",
|
||
"list_trash", "get_project_count", "get_task_count",
|
||
// 知识图谱 Phase 1 任务关联工具(register_task_graph_tools 6 个)
|
||
"create_task_link", "remove_task_link", "list_task_links",
|
||
"get_task_tree", "move_task_queue", "update_content",
|
||
// 知识图谱 Phase 2 项目事件流(register_task_graph_tools 7 个,本工具为第 7)
|
||
"get_project_timeline",
|
||
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
||
"add_project_service", "list_project_services",
|
||
// 工程系统:项目工程列表查询
|
||
"list_project_modules",
|
||
// 工程模块写工具(2026-08-08):create/update/delete 工程 + 依赖关系管理
|
||
"create_module", "update_module", "delete_module",
|
||
"list_module_dependencies", "add_module_dependency", "remove_module_dependency",
|
||
// Git 只读工具
|
||
"git_status", "git_diff", "git_log",
|
||
// Git 写工具
|
||
"git_commit", "git_branch", "git_merge",
|
||
// 知识库工具(2026-09-29):检索已发布知识 + 沉淀候选知识,打通对话侧与知识库
|
||
"knowledge_search", "knowledge_insert",
|
||
// 候选知识显式转正(2026-10-01):candidate→published 单向,对话内审核通道
|
||
"knowledge_promote",
|
||
// ── file 层 (15) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
|
||
// download_file 新增跨平台 URL→文件流式下载;diff_files 新增任意两文件对比)
|
||
"read_file", "read_symbol", "list_directory", "write_file",
|
||
"patch_file", "file_info", "append_file",
|
||
"delete_file", "rename_file", "search_files", "run_command",
|
||
"grep", "detect_environment",
|
||
// download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools)
|
||
"download_file",
|
||
// diff_files(任意两文件内容对比,只读 Low,补 git_diff 仅仓库内缺口)
|
||
"diff_files",
|
||
// ── http 层 (1) ──
|
||
"http_request",
|
||
// ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
|
||
"fetch_url",
|
||
// ── fetch_search 层 (1) ──(搜索引擎查询 DDG HTML 免 key,只读 GET,补 fetch_url 缺口)
|
||
"fetch_search",
|
||
// ── generate_image 层 (1) ──(调 provider 图像生成端点 + 下载落地,默认 sensenova-u1-fast)
|
||
"generate_image",
|
||
// ── get_app_config 层 (1) ──(查 DevFlow 自身当前 AI 配置,只读 + api_key 脱敏,
|
||
// 治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
|
||
"get_app_config",
|
||
// ── local_proxy 层 (11) ──(本地 4 个工具代理:mysql/ssh/mongo/redis,
|
||
// 只读查询 Low / 数据写 High / 轻量写 Medium,声明式注册 tools/local_proxy.rs)
|
||
"list_proxy_connections", "mysql_query", "mysql_execute",
|
||
"ssh_exec", "mongo_find", "mongo_insert",
|
||
"mongo_update", "mongo_delete", "redis_get",
|
||
"redis_set", "redis_del",
|
||
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
|
||
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
|
||
"read_skill",
|
||
// ── spawn_agent 层 (1) ──(派发子代理隔离上下文执行专项任务,Medium 审批档,
|
||
// 独立 agentic 循环 agentic/sub_agent.rs,声明式注册 tools/spawn_agent.rs)
|
||
"spawn_agent",
|
||
];
|
||
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_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||
#[test]
|
||
fn test_is_noise_entry_distinguishes_dir_and_gitignore_file() {
|
||
// 噪音目录命中
|
||
assert!(is_noise_entry(".git", NoiseDirTier::AiContext));
|
||
assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext));
|
||
assert!(is_noise_entry("target", NoiseDirTier::AiContext));
|
||
assert!(is_noise_entry("dist", NoiseDirTier::AiContext));
|
||
assert!(is_noise_entry("build", NoiseDirTier::AiContext));
|
||
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
|
||
assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext));
|
||
}
|
||
|
||
/// is_noise_entry 纯函数:普通目录不命中
|
||
#[test]
|
||
fn test_is_noise_entry_normal_dirs_not_matched() {
|
||
assert!(!is_noise_entry("src", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry("tests", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry("docs", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry("", NoiseDirTier::AiContext));
|
||
}
|
||
|
||
/// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
|
||
#[test]
|
||
fn test_is_noise_entry_case_sensitive() {
|
||
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
|
||
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
|
||
assert!(!is_noise_entry("Target", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext));
|
||
assert!(!is_noise_entry("NODE_MODULES", NoiseDirTier::AiContext));
|
||
}
|
||
|
||
/// is_noise_entry 档位差异锁定:文件树放行 build/venv,技能扫描仅两项
|
||
#[test]
|
||
fn test_is_noise_entry_tier_deltas() {
|
||
// FileTree:全量名单放行 build 与 venv(用户浏览要见构建产物),其余与 AiContext 一致
|
||
assert!(!is_noise_entry("build", NoiseDirTier::FileTree));
|
||
assert!(!is_noise_entry("venv", NoiseDirTier::FileTree));
|
||
assert!(is_noise_entry("node_modules", NoiseDirTier::FileTree));
|
||
assert!(is_noise_entry("target", NoiseDirTier::FileTree));
|
||
assert!(is_noise_entry("dist", NoiseDirTier::FileTree));
|
||
assert!(is_noise_entry("__pycache__", NoiseDirTier::FileTree));
|
||
assert!(!is_noise_entry("src", NoiseDirTier::FileTree));
|
||
// SkillScan:最小集,仅依赖/VCS
|
||
assert!(is_noise_entry("node_modules", NoiseDirTier::SkillScan));
|
||
assert!(is_noise_entry(".git", NoiseDirTier::SkillScan));
|
||
assert!(!is_noise_entry("target", NoiseDirTier::SkillScan));
|
||
assert!(!is_noise_entry("build", NoiseDirTier::SkillScan));
|
||
}
|
||
|
||
/// 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(), once: 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, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
|
||
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["truncated"], 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(), once: 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, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
|
||
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["truncated"], true, "600 行只取 500 行,应有更多");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
// ============================================================
|
||
// P1 修复:UTF-16 LE/BE BOM 文件解码(PowerShell Out-File/> 产 UTF-16 LE,
|
||
// 旧 read_to_string 因 ASCII 高字节 0x00 判 InvalidData → 误判二进制拒读)
|
||
// decode_bytes_to_string 纯函数单测 + read_file 端到端 + 真 binary 仍拒读
|
||
// ============================================================
|
||
|
||
/// decode_bytes_to_string: UTF-16 LE BOM(FF FE) → 可读 UTF-8
|
||
#[test]
|
||
fn test_decode_bytes_utf16_le_bom() {
|
||
// "hello\n" UTF-16 LE 编码
|
||
let text = "hello\n";
|
||
let mut bytes: Vec<u8> = vec![0xFF, 0xFE]; // LE BOM
|
||
for u in text.encode_utf16() {
|
||
bytes.extend_from_slice(&u.to_le_bytes());
|
||
}
|
||
let s = decode_bytes_to_string(&bytes).expect("UTF-16 LE BOM 应解码成功");
|
||
assert_eq!(s, text, "UTF-16 LE BOM 文件应解码为原文本");
|
||
}
|
||
|
||
/// decode_bytes_to_string: UTF-16 BE BOM(FE FF) → 可读 UTF-8
|
||
#[test]
|
||
fn test_decode_bytes_utf16_be_bom() {
|
||
// 中文 + ASCII,UTF-16 BE 编码(验证非 ASCII 码点 + 大端序)
|
||
let text = "中文 ascii\n";
|
||
let mut bytes: Vec<u8> = vec![0xFE, 0xFF]; // BE BOM
|
||
for u in text.encode_utf16() {
|
||
bytes.extend_from_slice(&u.to_be_bytes());
|
||
}
|
||
let s = decode_bytes_to_string(&bytes).expect("UTF-16 BE BOM 应解码成功");
|
||
assert_eq!(s, text, "UTF-16 BE BOM 文件应解码为原文本(含中文码点)");
|
||
}
|
||
|
||
/// decode_bytes_to_string: 无 BOM 真 binary(密集 \x00 非 BOM 前缀) → InvalidData
|
||
#[test]
|
||
fn test_decode_bytes_real_binary_rejected() {
|
||
// 真二进制:图片/编译产物常见,前两字节非 BOM 模式且含密集 NUL
|
||
let bin: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0x0D, 0x00, 0x0A]; // PNG 头片段
|
||
let err = decode_bytes_to_string(&bin).expect_err("真二进制应解码失败");
|
||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "真二进制返 InvalidData");
|
||
}
|
||
|
||
/// decode_bytes_to_string: 普通 UTF-8 无 BOM → 透传(回归保护)
|
||
#[test]
|
||
fn test_decode_bytes_plain_utf8_passthrough() {
|
||
let bytes = "普通 UTF-8 文本\n".as_bytes();
|
||
let s = decode_bytes_to_string(bytes).expect("UTF-8 无 BOM 应直接通过");
|
||
assert_eq!(s, "普通 UTF-8 文本\n");
|
||
}
|
||
|
||
/// read_file 端到端:UTF-16 LE BOM 文件经 registry.execute 读出正常 content + binary=false
|
||
#[tokio::test]
|
||
async fn test_read_file_utf16_le_bom_decodes() {
|
||
let tmp = std::env::temp_dir().join(format!("df_readfile_utf16le_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// 造 UTF-16 LE BOM 文件(模拟 PowerShell Out-File 产物)
|
||
let text = "line1\nline2\nPowerShell output\n";
|
||
let mut bytes: Vec<u8> = vec![0xFF, 0xFE];
|
||
for u in text.encode_utf16() {
|
||
bytes.extend_from_slice(&u.to_le_bytes());
|
||
}
|
||
let file = tmp.join("ps_out.txt");
|
||
fs::write(&file, &bytes).unwrap();
|
||
|
||
let mut persistent = std::collections::HashSet::new();
|
||
persistent.insert(tmp.clone());
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
|
||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
|
||
|
||
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 执行失败");
|
||
// 关键断言:不再误判二进制,返正常文本内容
|
||
assert_eq!(res["binary"], serde_json::Value::Null, "UTF-16 LE BOM 不应判二进制");
|
||
let content = res["content"].as_str().expect("应返回 content 字段");
|
||
assert!(content.contains("line1"), "解码内容应含 line1,实际: {}", content);
|
||
assert!(content.contains("PowerShell output"), "解码内容应含原文,实际: {}", content);
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
/// read_file 端到端:真 binary(密集 \x00 非 BOM)仍返 binary:true(拦截不破)
|
||
#[tokio::test]
|
||
async fn test_read_file_real_binary_still_rejected() {
|
||
let tmp = std::env::temp_dir().join(format!("df_readfile_bin_{}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&tmp);
|
||
fs::create_dir_all(&tmp).unwrap();
|
||
// PNG 签名 + 密集 NUL(真二进制,前两字节 89 50 非 BOM)
|
||
let bin: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0xFF, 0xFE];
|
||
let file = tmp.join("image.png");
|
||
fs::write(&file, &bin).unwrap();
|
||
|
||
let mut persistent = std::collections::HashSet::new();
|
||
persistent.insert(tmp.clone());
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
|
||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
|
||
|
||
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 执行失败");
|
||
assert_eq!(res["binary"], true, "真二进制(非 BOM 前缀)应仍被拦截返 binary:true");
|
||
assert_eq!(res["content"], serde_json::Value::Null, "真二进制 content 应为 null");
|
||
|
||
fs::remove_dir_all(&tmp).ok();
|
||
}
|
||
|
||
// ============================================================
|
||
// patch_file 三模式共用底层测试
|
||
// 纯函数 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: 子串匹配含行内出现(如 println!("{}") 内的 })——
|
||
/// end 唯一命中时放行,锁定「子串包含」语义(非「整行相等」)
|
||
#[test]
|
||
fn test_resolve_anchor_substring_matches_inside_line() {
|
||
// end 锚 "}" 唯一命中第 3 行 println!("{}") 的 }(第 4 行换成无 } 的行)
|
||
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\nEND\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 子串多处命中 → Err 列前两处命中行 + 修复提示
|
||
/// (契约变更:旧实现静默取第一个命中行,实测 ×2 LLM 短 end 锚点多处命中替换错位,
|
||
/// 根修改为唯一性校验——多处命中报错,提示缩小 end 锚点或改用 old_text)
|
||
#[test]
|
||
fn test_resolve_anchor_end_multi_hit_rejected() {
|
||
// 第 2、4 行都含 "}",多处命中 → Err
|
||
let content = "START\n}\nx\n}";
|
||
let err = resolve_anchor_to_lines(content, "START", "}").unwrap_err();
|
||
let msg = format!("{}", err);
|
||
assert!(msg.contains("命中 2 处"), "应报多处命中数,实际: {msg}");
|
||
assert!(msg.contains("第 2 行"), "应列第一处命中行,实际: {msg}");
|
||
assert!(msg.contains("第 4 行"), "应列第二处命中行,实际: {msg}");
|
||
assert!(msg.contains("缩小 end 锚点"), "应含缩小锚点提示,实际: {msg}");
|
||
assert!(msg.contains("old_text"), "应含改用 old_text 建议,实际: {msg}");
|
||
}
|
||
|
||
/// 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 工具测试
|
||
//
|
||
// 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");
|
||
}
|
||
|
||
// ============================================================
|
||
// 知识图谱 Phase 3(2026-06-27):add_project_service / list_project_services
|
||
// AI 工具 handler 端到端串联测试(经 registry.execute 验证参数装配 → Repo 落库 → 回读)。
|
||
// service_type 白名单 + D10 凭证审查下沉 Repo 层(已在 project_service_repo.rs 单元测试覆盖),
|
||
// 此处锁定 AI handler 的参数装配 / 调用路径正确(防 handler 拼参错位 / 漏调 insert_validated)。
|
||
// ============================================================
|
||
|
||
/// 造占位项目(project_services.project_id FK 要求 projects 存在)+ registry fixture。
|
||
async fn setup_services_registry() -> (Arc<Database>, AiToolRegistry) {
|
||
let db = Database::open_in_memory().await.expect("in-memory db");
|
||
let db = Arc::new(db);
|
||
// 占位 project 满足 FK
|
||
let repo = df_storage::crud::ProjectRepo::new(&db);
|
||
repo.insert(ProjectRecord {
|
||
id: "proj-svc-1".to_string(),
|
||
name: "proj-svc-1".to_string(),
|
||
description: String::new(),
|
||
status: ProjectStatus::Planning,
|
||
idea_id: None,
|
||
path: None,
|
||
stack: None,
|
||
created_at: now_millis(),
|
||
updated_at: now_millis(),
|
||
})
|
||
.await
|
||
.unwrap();
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(), make_test_spawn_agent_ctx(&db));
|
||
(db, registry)
|
||
}
|
||
|
||
/// add_project_service 正常路径:合法 service_type + 无凭证 → 落库 + 回读完整记录。
|
||
#[tokio::test]
|
||
async fn test_add_project_service_tool_inserts() {
|
||
let (_db, registry) = setup_services_registry().await;
|
||
let args = serde_json::json!({
|
||
"project_id": "proj-svc-1",
|
||
"name": "主库",
|
||
"service_type": "mysql",
|
||
"environment": "production",
|
||
"endpoint": "localhost:3306",
|
||
"config_json": "{\"pool_size\":10}",
|
||
"remark": "主库"
|
||
});
|
||
let res = registry
|
||
.execute("add_project_service", args)
|
||
.await
|
||
.expect("add_project_service 执行失败");
|
||
assert_eq!(res["project_id"], "proj-svc-1");
|
||
assert_eq!(res["name"], "主库");
|
||
assert_eq!(res["service_type"], "mysql");
|
||
assert_eq!(res["environment"], "production");
|
||
assert_eq!(res["endpoint"], "localhost:3306");
|
||
assert!(res["id"].as_str().is_some(), "应返回生成的 id");
|
||
}
|
||
|
||
/// add_project_service D10 安全边界:config_json 含 password → handler 返 Err(不落库)。
|
||
#[tokio::test]
|
||
async fn test_add_project_service_tool_rejects_credential() {
|
||
let (db, registry) = setup_services_registry().await;
|
||
let args = serde_json::json!({
|
||
"project_id": "proj-svc-1",
|
||
"name": "主库",
|
||
"service_type": "mysql",
|
||
"config_json": "{\"password\":\"secret123\"}"
|
||
});
|
||
let err = registry.execute("add_project_service", args).await;
|
||
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
|
||
// 未落库
|
||
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
|
||
let all = repo.list_by_project("proj-svc-1").await.unwrap();
|
||
assert!(all.is_empty(), "拒绝后不应有记录");
|
||
}
|
||
|
||
/// list_project_services 查询:插 2 条(不同环境)→ 不带 environment 返回全部,
|
||
/// 带 environment 仅返回匹配。
|
||
#[tokio::test]
|
||
async fn test_list_project_services_tool_filters_environment() {
|
||
let (_db, registry) = setup_services_registry().await;
|
||
// 插 development + production 各 1 条
|
||
for (st, env) in [("mysql", "development"), ("redis", "production")] {
|
||
registry
|
||
.execute(
|
||
"add_project_service",
|
||
serde_json::json!({
|
||
"project_id": "proj-svc-1",
|
||
"name": format!("svc-{env}"),
|
||
"service_type": st,
|
||
"environment": env,
|
||
}),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
}
|
||
// 不带 environment:返回全部 2 条
|
||
let all = registry
|
||
.execute(
|
||
"list_project_services",
|
||
serde_json::json!({ "project_id": "proj-svc-1" }),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(all["total"], 2);
|
||
assert_eq!(all["items"].as_array().unwrap().len(), 2);
|
||
|
||
// 仅 development:1 条
|
||
let dev = registry
|
||
.execute(
|
||
"list_project_services",
|
||
serde_json::json!({ "project_id": "proj-svc-1", "environment": "development" }),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(dev["total"], 1);
|
||
assert_eq!(dev["items"][0]["environment"], "development");
|
||
assert_eq!(dev["items"][0]["service_type"], "mysql");
|
||
|
||
// 不存在的环境:0 条
|
||
let staging = registry
|
||
.execute(
|
||
"list_project_services",
|
||
serde_json::json!({ "project_id": "proj-svc-1", "environment": "staging" }),
|
||
)
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(staging["total"], 0);
|
||
}
|
||
|
||
// ============================================================
|
||
// DRY 债 #5 对照验证:5 处旧 match 表收敛到注册元数据后逐工具比对。
|
||
//
|
||
// 旧表值在本测试内固化为期望数据(收敛前从各 match 表逐行抄录):
|
||
// path_auth 路径参数键 / cache 只读白名单 + 目录级 key / data_change entity-action /
|
||
// tool_registry 审批模板 + 轻量标签 / helpers 目标描述 + 漂移签名(按样例 args 比对输出)。
|
||
// 任何一维漂移此测试立即红。
|
||
// ============================================================
|
||
#[tokio::test]
|
||
async fn test_tool_meta_parity_with_legacy_tables() {
|
||
// 真实构建一次注册表(条目与全局表一致性断言用)
|
||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||
let registry = build_ai_tool_registry(
|
||
&db, &allowed_dirs, PathBuf::from(""),
|
||
make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(),
|
||
make_test_spawn_agent_ctx(&db),
|
||
);
|
||
ensure_tool_meta_seeded();
|
||
|
||
// ── 旧 path_auth::extract_file_tool_paths 表(name → 路径参数键)──
|
||
let legacy_path_args: &[(&str, &[&str])] = &[
|
||
("rename_file", &["from", "to"]),
|
||
("read_file", &["path"]), ("write_file", &["path"]), ("list_directory", &["path"]),
|
||
("patch_file", &["path"]), ("file_info", &["path"]), ("append_file", &["path"]),
|
||
("delete_file", &["path"]), ("search_files", &["path"]), ("grep", &["path"]),
|
||
];
|
||
for (name, keys) in legacy_path_args {
|
||
assert_eq!(df_ai::ai_tools::path_arg_keys(name), *keys, "path_args 漂移: {}", name);
|
||
}
|
||
// 旧 default:非文件工具返回空(不预校验)
|
||
for name in ["run_command", "http_request", "create_task", "git_status", "read_symbol", "download_file", "diff_files"] {
|
||
assert!(df_ai::ai_tools::path_arg_keys(name).is_empty(), "非文件工具不应有 path_args: {}", name);
|
||
}
|
||
|
||
// ── 旧 cache.rs READONLY_CACHE_TOOLS 白名单 ──
|
||
for name in ["read_file", "read_symbol", "list_directory", "search_files", "grep"] {
|
||
assert!(df_ai::ai_tools::is_readonly(name), "readonly 漂移: {}", name);
|
||
}
|
||
for name in ["write_file", "patch_file", "run_command", "delete_file", "rename_file", "append_file", "file_info", "http_request", "git_commit", "mysql_query"] {
|
||
assert!(!df_ai::ai_tools::is_readonly(name), "非只读工具误标 readonly: {}", name);
|
||
}
|
||
|
||
// ── 旧 cache.rs 目录级 key / 目录专用警告判据(旧匹配 list_directory;幽灵 list_dir 已删)──
|
||
assert!(df_ai::ai_tools::cache_path_key_only("list_directory"));
|
||
for name in ["read_file", "grep", "search_files", "list_dir"] {
|
||
assert!(!df_ai::ai_tools::cache_path_key_only(name), "cache_path_key 误标: {}", name);
|
||
}
|
||
|
||
// ── 旧 path_auth search_files 盲搜硬拒 ──
|
||
assert!(df_ai::ai_tools::denies_unauthorized("search_files"));
|
||
for name in ["grep", "read_file", "list_directory", "run_command"] {
|
||
assert!(!df_ai::ai_tools::denies_unauthorized(name), "deny_unauthorized 误标: {}", name);
|
||
}
|
||
|
||
// ── 旧 data_change::data_change_for_tool 表(name, entity, action)──
|
||
let legacy_entity_action: &[(&str, &str, &str)] = &[
|
||
("create_project", "project", "create"),
|
||
("create_task", "task", "create"),
|
||
("create_idea", "idea", "create"),
|
||
("update_project", "project", "update"),
|
||
("update_task", "task", "update"),
|
||
("update_idea", "idea", "update"),
|
||
("advance_task", "task", "update"),
|
||
("delete_project", "project", "delete"),
|
||
("purge_project", "project", "delete"),
|
||
("delete_task", "task", "delete"),
|
||
("delete_idea", "idea", "delete"),
|
||
("restore_project", "project", "update"),
|
||
("bind_directory", "project", "update"),
|
||
("write_file", "file", "update"),
|
||
("patch_file", "file", "update"),
|
||
];
|
||
for (name, entity, action) in legacy_entity_action {
|
||
assert_eq!(
|
||
df_ai::ai_tools::entity_action(name),
|
||
Some((*entity, *action)),
|
||
"entity_action 漂移: {}", name
|
||
);
|
||
}
|
||
for name in ["list_tasks", "run_workflow", "read_file", "git_status"] {
|
||
assert_eq!(df_ai::ai_tools::entity_action(name), None, "entity_action 应为 None: {}", name);
|
||
}
|
||
|
||
// ── 旧 tool_registry display_hint_for_tool 表(name, 模板, 键)──
|
||
let legacy_approval: &[(&str, &str, &[&str])] = &[
|
||
("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"]),
|
||
("advance_task", "推进任务状态:{} → {}", &["id", "target_status"]),
|
||
("run_workflow", "触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
|
||
("http_request", "HTTP 请求:{} {}", &["method", "url"]),
|
||
];
|
||
for (name, template, keys) in legacy_approval {
|
||
assert_eq!(
|
||
df_ai::ai_tools::approval_hint(name),
|
||
Some((*template, *keys)),
|
||
"approval_hint 漂移: {}", name
|
||
);
|
||
}
|
||
assert_eq!(df_ai::ai_tools::approval_hint("read_file"), None);
|
||
|
||
// ── 旧 tool_registry tool_display_hint 表(advance_task 刻意不在表内)──
|
||
let legacy_verb: &[(&str, &str)] = &[
|
||
("create_task", "创建任务"), ("write_file", "写入文件"), ("read_file", "读取文件"),
|
||
("list_directory", "列出目录"), ("search_files", "搜索文件"), ("grep", "搜索代码内容"),
|
||
("create_project", "创建项目"), ("update_project", "修改项目"), ("delete_project", "删除项目"),
|
||
("restore_project", "恢复项目"), ("purge_project", "永久删除项目"), ("bind_directory", "绑定目录"),
|
||
("create_idea", "捕获灵感"), ("update_task", "修改任务"), ("run_workflow", "触发工作流"),
|
||
("delete_task", "删除任务"), ("update_idea", "修改灵感"), ("delete_idea", "删除灵感"),
|
||
("patch_file", "修改文件"), ("append_file", "追加写入"), ("delete_file", "删除文件"),
|
||
("rename_file", "重命名/移动"), ("http_request", "发起 HTTP 请求"),
|
||
];
|
||
for (name, verb) in legacy_verb {
|
||
assert_eq!(df_ai::ai_tools::display_verb(name), Some(*verb), "display_verb 漂移: {}", name);
|
||
}
|
||
assert_eq!(df_ai::ai_tools::display_verb("advance_task"), None, "advance_task 无轻量标签(走模板),与旧表一致");
|
||
assert_eq!(df_ai::ai_tools::display_verb("read_symbol"), None);
|
||
|
||
// ── 旧 helpers::infer_goal_from_tool_calls(样例 args 逐字比对输出)──
|
||
let goal_of = |name: &str, args: Option<serde_json::Value>| -> Option<String> {
|
||
df_ai::ai_tools::goal_of(name).and_then(|g| df_ai::ai_tools::goal_text(g, args.as_ref()))
|
||
};
|
||
// FileName 风格:按旧实现取末个 / 段(纯反斜杠路径旧 or_else 分支恒死,保持原样)
|
||
assert_eq!(goal_of("patch_file", Some(serde_json::json!({"path": "a/b/c.rs"}))), Some("修改 c.rs".to_string()));
|
||
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "docs/dry.md"}))), Some("修改 dry.md".to_string()));
|
||
assert_eq!(goal_of("append_file", Some(serde_json::json!({"path": "log.txt"}))), Some("修改 log.txt".to_string()));
|
||
assert_eq!(goal_of("read_file", Some(serde_json::json!({"path": "src/lib.rs"}))), Some("查看 lib.rs".to_string()));
|
||
assert_eq!(goal_of("file_info", Some(serde_json::json!({"path": "Cargo.toml"}))), Some("查看 Cargo.toml".to_string()));
|
||
assert_eq!(goal_of("delete_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
|
||
assert_eq!(goal_of("rename_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
|
||
// 反斜杠路径:旧实现 rsplit('/') 取整串(or_else 恒不触发),新实现保持同值
|
||
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "x\\y\\n.txt"}))), Some("修改 x\\y\\n.txt".to_string()));
|
||
// Raw 风格
|
||
assert_eq!(goal_of("list_directory", Some(serde_json::json!({"path": "/tmp/logs"}))), Some("浏览 /tmp/logs".to_string()));
|
||
assert_eq!(goal_of("create_project", Some(serde_json::json!({"name": "moyu"}))), Some("创建项目 moyu".to_string()));
|
||
assert_eq!(goal_of("create_idea", Some(serde_json::json!({"title": "灵感一闪"}))), Some("捕获灵感 灵感一闪".to_string()));
|
||
// Truncate 风格 + 键回退 + 缺参兜底
|
||
let long_cmd = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||
assert_eq!(goal_of("run_command", Some(serde_json::json!({"command": long_cmd}))), Some(format!("执行 {}", &long_cmd[..25])));
|
||
assert_eq!(goal_of("search_files", Some(serde_json::json!({"pattern": long_cmd}))), Some(format!("搜索 {}", &long_cmd[..25])));
|
||
assert_eq!(goal_of("grep", Some(serde_json::json!({"query": "todo"}))), Some("搜索 todo".to_string()));
|
||
assert_eq!(goal_of("search_files", Some(serde_json::json!({}))), Some("搜索文件".to_string()));
|
||
assert_eq!(goal_of("run_command", Some(serde_json::json!({}))), Some("执行命令".to_string()));
|
||
// 无键工具恒返兜底(旧 run_workflow 无条件 Some)
|
||
assert_eq!(goal_of("run_workflow", Some(serde_json::json!({"task_id": "t1"}))), Some("执行工作流".to_string()));
|
||
assert_eq!(goal_of("run_workflow", None), Some("执行工作流".to_string()));
|
||
// 键缺失且无兜底返 None(旧表 _ => None 分支)
|
||
assert_eq!(goal_of("read_file", Some(serde_json::json!({}))), None);
|
||
assert_eq!(goal_of("update_task", Some(serde_json::json!({"id": "t1"}))), None);
|
||
|
||
// ── 旧 helpers::tool_call_signature(样例 args 逐字比对输出)──
|
||
let sig = |name: &str, args: serde_json::Value| df_ai::ai_tools::tool_signature(name, &args);
|
||
assert_eq!(sig("grep", serde_json::json!({"pattern": "MAX", "path": "src", "case_sensitive": true})), "grep:pattern=MAX,path=src");
|
||
assert_eq!(sig("search_files", serde_json::json!({"query": "TODO", "glob": "**/*.rs"})), "search_files:pattern=TODO,path=**/*.rs");
|
||
assert_eq!(sig("read_file", serde_json::json!({"path": "big.log", "offset": 50, "limit": 100})), "read_file:path=big.log,offset=50,limit=100");
|
||
assert_eq!(sig("read_file", serde_json::json!({})), "read_file:path=,offset=,limit=");
|
||
assert_eq!(sig("run_command", serde_json::json!({"command": "ls -la", "timeout": 5000})), "run_command:command=ls -la");
|
||
assert_eq!(sig("list_directory", serde_json::json!({"path": "/tmp"})), "list_directory:path=/tmp");
|
||
assert_eq!(sig("http_request", serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}})), "http_request:url=https://x.io,method=GET");
|
||
assert_eq!(sig("fetch_url", serde_json::json!({"url": "https://y.io", "raw": false})), "fetch_url:url=https://y.io");
|
||
// 未注册/未声明 sig 走旧 default:工具名 + 全参 JSON
|
||
let unknown = sig("custom_tool", serde_json::json!({"x": 1, "y": "z"}));
|
||
assert!(unknown.starts_with("custom_tool:name=custom_tool,args="), "未知工具应走全参兜底: {}", unknown);
|
||
let nosig = sig("write_file", serde_json::json!({"path": "a"}));
|
||
assert!(nosig.starts_with("write_file:name=write_file,args="), "未声明 sig 的已注册工具应走全参兜底: {}", nosig);
|
||
|
||
// ── 注册表条目与全局表一致(条目存储元数据 + 查询函数双面)──
|
||
for name in registry.tool_names() {
|
||
let entry = registry.meta(&name).expect("条目应有元数据");
|
||
assert_eq!(Some(entry), df_ai::ai_tools::tool_meta(&name), "条目与全局表漂移: {}", name);
|
||
}
|
||
// 抽查条目维度:read_file 只读 + path 路径键;advance_task 有 entity 无轻量标签
|
||
let rf = registry.meta("read_file").unwrap();
|
||
assert!(rf.readonly && rf.path_args == ["path"]);
|
||
let at = registry.meta("advance_task").unwrap();
|
||
assert_eq!(at.entity_action, Some(("task", "update")));
|
||
assert_eq!(at.display_verb, None);
|
||
}
|
||
}
|