1713 lines
96 KiB
Rust
1713 lines
96 KiB
Rust
//! 文件系统类 AI 工具声明式注册(register_file_tools 13 个迁入)。
|
||
//!
|
||
//! 迁自 `tool_registry.rs::register_file_tools`(原 13 个 read_file/read_symbol/
|
||
//! list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/
|
||
//! grep/search_files/run_command/detect_environment),改用 `declare_tool!` 宏。
|
||
//!
|
||
//! 迁移策略(handler 逻辑零变更):
|
||
//! - handler body 逐字照搬原 `register_file_tools` 内 async move 块(逻辑等价),
|
||
//! 仅闭包包装(`{ let allowed_dirs = ...; Box::new(move |args| { ... Box::pin(async move {...}) }) }`)
|
||
//! 改由 `declare_tool!` 宏生成。
|
||
//! - name/desc/schema/risk 与原手写定义逐字一致。
|
||
//! - 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有 fn/const 改 pub(crate)):
|
||
//! DEFAULT_RUN_COMMAND_TIMEOUT_SECS / MAX_RUN_COMMAND_TIMEOUT_SECS / truncate_output /
|
||
//! validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines /
|
||
//! resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash /
|
||
//! FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex /
|
||
//! grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file /
|
||
//! FileGrepHit / LineMatch。
|
||
//!
|
||
//! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量):
|
||
//! - 文件工具主体捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`。
|
||
//! - delete_file 额外捕获 data_dir:PathBuf,用打包 capture
|
||
//! `delete_ctx: Arc<(Arc<RwLock<AllowedDirs>>, PathBuf)>`,handler 首行解包为
|
||
//! `let allowed_dirs = &delete_ctx.0; let data_dir = &delete_ctx.1;`,其余 body 逐字不变。
|
||
//! - run_command/detect_environment 零捕获(原 `Box::new(|args| ...)` 不含 allowed_dirs),
|
||
//! 用 `Arc<()>` 占位 capture(handler body 不引用它),对齐 declare_tool_no_capture 测试模式。
|
||
//!
|
||
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
|
||
//! 工具名集合稳定(防 rename / 漏注册)。
|
||
|
||
use std::sync::Arc;
|
||
|
||
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||
use df_ai::declare_tool;
|
||
use tokio::sync::RwLock;
|
||
|
||
use crate::state::AllowedDirs;
|
||
|
||
// 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有项已改 pub(crate))
|
||
use crate::commands::ai::tool_registry::{
|
||
apply_line_range, compile_glob_to_regex, compute_file_hash, decode_bytes_to_string,
|
||
generate_diff, grep_one_file, grep_recursive, list_dir_recursive, probe_executable,
|
||
rename_or_cross_volume_copy, resolve_anchor_to_lines, resolve_workspace_path_with_allowed,
|
||
search_files_recursive, truncate_output, validate_path, DEFAULT_RUN_COMMAND_TIMEOUT_SECS,
|
||
MAX_RUN_COMMAND_TIMEOUT_SECS, FILE_LOCKS, FileGrepHit,
|
||
};
|
||
|
||
// run_command 依赖 df_execute::shell::{execute_streaming, ShellRequest, StreamKind} + std HashMap
|
||
// execute_streaming:run_command 专用流式(spawn 逐行读 stdout/stderr,回调 emit AiCommandOutput 治执行黑盒)。
|
||
// new_id: delete_file 软删除备份命名(原 register_file_tools 闭包引用,经 df_types::types::new_id)。
|
||
use df_execute::shell::{execute_streaming, ShellRequest, StreamKind};
|
||
use df_types::types::new_id;
|
||
use std::collections::HashMap;
|
||
|
||
/// 注册 13 个文件系统类工具到 `$registry`。
|
||
///
|
||
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
|
||
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
|
||
/// - risk 与原一致(read/symbol/list/file_info/search/grep/detect_environment=Low,
|
||
/// write/patch/append/rename=Medium,delete/run_command=High)
|
||
/// - handler body 与原 async move 块逐字一致(逻辑零变更,路径授权/risk/diff/patch 全保留)
|
||
///
|
||
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
|
||
pub fn register(
|
||
registry: &mut AiToolRegistry,
|
||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||
data_dir: std::path::PathBuf,
|
||
) {
|
||
// ── read_file (Low 只读) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"read_file",
|
||
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
// 单次 File::open 取 metadata+read 消除 TOCTOU原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
|
||
use tokio::fs::File;
|
||
use tokio::io::AsyncReadExt;
|
||
let mut file = File::open(path).await
|
||
.map_err(|e| {
|
||
if e.kind() == std::io::ErrorKind::NotFound {
|
||
file_not_found_error(path)
|
||
} else {
|
||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||
}
|
||
})?;
|
||
let metadata = file.metadata().await
|
||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||
if metadata.len() > 1_048_576 {
|
||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||
}
|
||
// TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。
|
||
// 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。
|
||
let file_hash = compute_file_hash(&metadata);
|
||
// P1 修复:原始字节读取 + UTF-16 LE/BE BOM 优先解码,治 PowerShell Out-File/> 产
|
||
// UTF-16 LE BOM 文件被 read_to_string 判 InvalidData(ASCII 高字节 0x00)→ 误判二进制拒读
|
||
// 致 run_command → 文件 → read_file 链路在 Windows 断裂。真二进制(无 BOM 非 UTF-8)
|
||
// 仍走 InvalidData 分支返 binary 标记拦截(图片/编译产物)。
|
||
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
|
||
if let Err(e) = file.read_to_end(&mut raw_bytes).await {
|
||
anyhow::bail!("读取文件失败: {}", e);
|
||
}
|
||
let content = match decode_bytes_to_string(&raw_bytes) {
|
||
Ok(s) => s,
|
||
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
|
||
return Ok(serde_json::json!({
|
||
"path": path, "content": null, "binary": true,
|
||
"size": metadata.len(), "file_hash": file_hash,
|
||
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
|
||
}));
|
||
}
|
||
Err(e) => anyhow::bail!("读取文件失败: {}", e),
|
||
};
|
||
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
|
||
if let Some(search) = args["search"].as_str() {
|
||
const SEARCH_MAX: usize = 50;
|
||
let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
|
||
// 先收集全部匹配行用于 total 计数,再做 skip/take 分页
|
||
let mut all_matches: Vec<serde_json::Value> = Vec::new();
|
||
for (idx, line) in content.lines().enumerate() {
|
||
if line.contains(search) {
|
||
all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
|
||
}
|
||
}
|
||
let total = all_matches.len();
|
||
let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
|
||
let has_more = (search_offset + page_matches.len()) < total;
|
||
return Ok(serde_json::json!({
|
||
"path": path, "size": metadata.len(), "file_hash": file_hash,
|
||
"search": search,
|
||
"matches": page_matches,
|
||
"total": total,
|
||
"has_more": has_more,
|
||
}));
|
||
}
|
||
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
|
||
// 无 offset 时旧实现 content.clone() 全量返回大文件,
|
||
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
|
||
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
|
||
let line_count = content.lines().count();
|
||
let (result, offset_used, has_more, limit) = if let Some(offset) = args["offset"].as_u64() {
|
||
let lines: Vec<&str> = content.lines().collect();
|
||
let skip = offset as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
|
||
let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
|
||
let more = (skip + page.len()) < line_count;
|
||
(page.join("\n"), Some(skip), more, limit)
|
||
} else {
|
||
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
|
||
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
|
||
// 旧实现固定 take(500) 忽略 limit,
|
||
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||
const DEFAULT_PREVIEW_LINES: usize = 500;
|
||
let limit = args["limit"].as_u64()
|
||
.unwrap_or(DEFAULT_PREVIEW_LINES as u64)
|
||
.min(2000) as usize;
|
||
let page: Vec<&str> = content.lines().take(limit).collect();
|
||
let more = line_count > page.len();
|
||
(page.join("\n"), None, more, limit)
|
||
};
|
||
// 截断明文化:治模型把 has_more 当"被压缩/损坏"反复重读同文件(实证 read_file 同文件重复读 6 次)。
|
||
// returned_lines=本次返回行数,consumed=累计已读(offset+本次),truncated_info 明确剩余行数+续读方式。
|
||
let returned_lines = result.lines().count();
|
||
let consumed = offset_used.unwrap_or(0) + returned_lines;
|
||
let truncated_info = if has_more {
|
||
Some(format!(
|
||
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={} 参数(可配合 limit={limit})",
|
||
offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()),
|
||
line_count - consumed,
|
||
consumed,
|
||
))
|
||
} else { None };
|
||
// 机制提示(AC-4,治弱模型 read_file 全量回灌):内容读取成功且**符号级命中**检测通过
|
||
// (内容里真的含可提取的函数/类/结构体定义)才追加 read_symbol 引导——比旧按扩展名
|
||
// 判断更准,无定义的文件不瞎提示。用全量 content(非分页 result)检测,符号可能在
|
||
// offset 范围外。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
|
||
let ext = path.rsplit('.').next().unwrap_or("").to_lowercase();
|
||
let content_field = if offset_used.is_none()
|
||
&& crate::commands::ai::code_intel::contains_definition(&content, &ext)
|
||
{
|
||
format!("{result}\n[提示] 如需定位该文件的函数/类/符号,请改用 read_symbol(路径, 符号名) 精确提取,避免读全文件浪费上下文。")
|
||
} else {
|
||
result
|
||
};
|
||
Ok(serde_json::json!({
|
||
"path": path, "content": content_field, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
|
||
"offset": offset_used,
|
||
"returned_lines": returned_lines,
|
||
"truncated": has_more,
|
||
"truncated_info": truncated_info,
|
||
}))
|
||
}
|
||
);
|
||
|
||
// ── read_symbol (Low 只读, AST 符号解析) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"read_symbol",
|
||
"AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]),
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?;
|
||
let full = args["full"].as_bool().unwrap_or(false);
|
||
let kind_hint = args["kind"].as_str();
|
||
let drill = args["drill"].as_str().filter(|s| !s.is_empty());
|
||
use tokio::fs::File;
|
||
use tokio::io::AsyncReadExt;
|
||
let mut file = File::open(path).await
|
||
.map_err(|e| {
|
||
if e.kind() == std::io::ErrorKind::NotFound {
|
||
file_not_found_error(path)
|
||
} else {
|
||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||
}
|
||
})?;
|
||
let metadata = file.metadata().await
|
||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||
if metadata.len() > 1_048_576 {
|
||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||
}
|
||
let file_hash = compute_file_hash(&metadata);
|
||
// P1 闭环(对齐 read_file L106-124):read_symbol 同受 UTF-16 BOM 硬失败影响——
|
||
// PowerShell Out-File/> 产 UTF-16 LE BOM 文件 read_to_string 判 InvalidData
|
||
// → read_symbol 误判二进制回退提示,治本改 read_to_end + decode_bytes_to_string(BOM 优先解码)。
|
||
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
|
||
if let Err(e) = file.read_to_end(&mut raw_bytes).await {
|
||
anyhow::bail!("读取文件失败: {}", e);
|
||
}
|
||
let content = match decode_bytes_to_string(&raw_bytes) {
|
||
Ok(s) => s,
|
||
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
|
||
return Ok(serde_json::json!({
|
||
"path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash,
|
||
"fallback": true, "reason": "binary",
|
||
"suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
|
||
}));
|
||
}
|
||
Err(e) => anyhow::bail!("读取文件失败: {}", e),
|
||
};
|
||
// 调 code_intel 纯函数(三态 + 兜底,不 panic)
|
||
Ok(crate::commands::ai::code_intel::read_symbol(
|
||
&content, &file_hash, path, symbol, full, kind_hint, drill,
|
||
))
|
||
}
|
||
);
|
||
|
||
// ── diff_files (Low 只读, 任意两文件对比) ──
|
||
// 补 git_diff 缺口:git_diff 只比仓库内版本,本工具对比任意两文件(绝对路径),
|
||
// 复用 generate_diff(LCS unified diff)生成差异,只读无副作用。
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"diff_files",
|
||
"对比任意两个文件的差异,返回 unified diff(+/- 行前缀,基于 LCS)。参数 path_a/path_b 为两文件绝对路径,均须在授权目录内。只读无副作用,用于两文件/两版本内容对比(git_diff 仅仓库内,本工具补任意两文件缺口)",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![("path_a", "string", true), ("path_b", "string", true)]),
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let a_resolved = resolve_workspace_path_with_allowed(
|
||
args["path_a"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path_a 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let b_resolved = resolve_workspace_path_with_allowed(
|
||
args["path_b"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path_b 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let a_path = a_resolved.to_str().ok_or_else(|| anyhow::anyhow!("path_a 含非法字符"))?.to_string();
|
||
let b_path = b_resolved.to_str().ok_or_else(|| anyhow::anyhow!("path_b 含非法字符"))?.to_string();
|
||
if a_path == b_path {
|
||
anyhow::bail!("两个路径相同,无需对比: {}", a_path);
|
||
}
|
||
// 读取两文件文本(1MB 上限 + 二进制拦截,解码对齐 read_file 的 UTF-16 BOM 优先)
|
||
let (content_a, size_a) = read_text_file(&a_path).await?;
|
||
let (content_b, size_b) = read_text_file(&b_path).await?;
|
||
let diff = generate_diff(&content_a, &content_b);
|
||
Ok(serde_json::json!({
|
||
"path_a": a_path,
|
||
"path_b": b_path,
|
||
"size_a": size_a,
|
||
"size_b": size_b,
|
||
"identical": content_a == content_b,
|
||
"diff": diff,
|
||
}))
|
||
}
|
||
);
|
||
|
||
// ── list_directory (Low 只读) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"list_directory",
|
||
"列出目录内容,返回文件和子目录列表(名称、类型、大小)。path 可选,不传时返回引导提示",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
|
||
args => {
|
||
let p = args["path"].as_str().unwrap_or("");
|
||
if p.is_empty() {
|
||
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
|
||
}
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
|
||
// max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
|
||
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
|
||
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
|
||
let mut entries = Vec::new();
|
||
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
|
||
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
|
||
}
|
||
);
|
||
|
||
// ── write_file (Medium 写) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"write_file",
|
||
"写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
|
||
RiskLevel::Medium,
|
||
schema: object_schema(vec![
|
||
("path", "string", true),
|
||
("content", "string", true),
|
||
("encoding", "string", false),
|
||
]),
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||
// encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
|
||
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
|
||
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
|
||
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
|
||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||
STANDARD.decode(raw)
|
||
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
|
||
} else if encoding.eq_ignore_ascii_case("utf-8") {
|
||
raw.as_bytes().to_vec()
|
||
} else {
|
||
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64)", encoding);
|
||
};
|
||
// 写入大小上限原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
|
||
if bytes.len() > 1_048_576 {
|
||
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
|
||
}
|
||
let target = std::path::Path::new(path);
|
||
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
|
||
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
|
||
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
|
||
Ok(m) if m.len() > 0 => {
|
||
let bak = format!("{}.bak", path);
|
||
tokio::fs::copy(path, &bak).await
|
||
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
|
||
Some(m.len())
|
||
}
|
||
Ok(_) => Some(0), // 空文件(无需备份)
|
||
Err(_) => None, // 不存在(新建)
|
||
};
|
||
if let Some(parent) = target.parent() {
|
||
// parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
|
||
// 原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
|
||
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
|
||
if !snap.is_authorized(parent) {
|
||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||
}
|
||
tokio::fs::create_dir_all(parent).await
|
||
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||
}
|
||
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
|
||
let tmp = format!("{}.tmp-write", path);
|
||
if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
|
||
let _ = tokio::fs::remove_file(&tmp).await;
|
||
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
|
||
}
|
||
if let Err(e) = tokio::fs::rename(&tmp, path).await {
|
||
let _ = tokio::fs::remove_file(&tmp).await;
|
||
return Err(anyhow::anyhow!("原子替换失败: {}", e));
|
||
}
|
||
// rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
|
||
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
|
||
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
|
||
if old_size.map(|s| s > 0).unwrap_or(false) {
|
||
let bak = format!("{}.bak", path);
|
||
let _ = tokio::fs::remove_file(&bak).await;
|
||
}
|
||
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
|
||
if let Some(old) = old_size {
|
||
if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
|
||
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
|
||
}
|
||
}
|
||
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
|
||
}
|
||
);
|
||
|
||
// ── patch_file (Medium, 三模式互斥) ──
|
||
// object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。
|
||
let patch_file_schema = {
|
||
let mut props = serde_json::Map::new();
|
||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" }));
|
||
props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" }));
|
||
props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" }));
|
||
props.insert("replace_lines".into(), serde_json::json!({
|
||
"type": "object",
|
||
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移",
|
||
"properties": {
|
||
"start": { "type": "integer", "description": "起始行号(1-based,含)" },
|
||
"end": { "type": "integer", "description": "结束行号(1-based,含)" }
|
||
},
|
||
"required": ["start", "end"]
|
||
}));
|
||
props.insert("anchor".into(), serde_json::json!({
|
||
"type": "object",
|
||
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
|
||
"properties": {
|
||
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
|
||
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
|
||
},
|
||
"required": ["start", "end"]
|
||
}));
|
||
props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "(old_text 模式)行号辅助定位,可选" }));
|
||
props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹(read_file 返回的 file_hash),三模式通用,防并发修改" }));
|
||
serde_json::json!({
|
||
"type": "object",
|
||
"properties": props,
|
||
"required": ["path", "new_text"],
|
||
})
|
||
};
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"patch_file",
|
||
"局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||
RiskLevel::Medium,
|
||
schema: patch_file_schema,
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let new_text = args["new_text"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
|
||
|
||
// 三模式互斥校验(old_text / replace_lines / anchor)
|
||
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
|
||
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
|
||
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
|
||
let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
|
||
let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count();
|
||
if mode_count == 0 {
|
||
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
|
||
}
|
||
if mode_count > 1 {
|
||
anyhow::bail!(
|
||
"模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)",
|
||
mode_count
|
||
);
|
||
}
|
||
|
||
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
|
||
let target = std::path::Path::new(path);
|
||
if !target.exists() {
|
||
anyhow::bail!("文件不存在: {}", path);
|
||
}
|
||
let file_meta = tokio::fs::metadata(target).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||
if file_meta.len() > 1_048_576 {
|
||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
|
||
}
|
||
|
||
// 读改写整体锁内防 lost update。
|
||
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
|
||
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
|
||
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
|
||
// 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。
|
||
let _patch_guard = FILE_LOCKS.lock().await;
|
||
|
||
// 读文件内容 + 校验(锁内,纯读 + CPU 计算)
|
||
// P1 闭环(对齐 read_file L106-124):patch_file 同受 UTF-16 BOM 硬失败影响——
|
||
// read_to_string 对 UTF-16 LE BOM 文件判 InvalidData,旧实现直接 bail 中断 patch,
|
||
// 用户 PowerShell 编辑的 UTF-16 文件无法 patch。改 read_to_end + decode_bytes_to_string
|
||
// (BOM 优先解码),解码后内容替换等基于正确解码的字符串,new_content 写回 UTF-8。
|
||
use tokio::io::AsyncReadExt;
|
||
let mut file = tokio::fs::File::open(path).await
|
||
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
|
||
let mut raw_bytes = Vec::with_capacity(file_meta.len() as usize);
|
||
file.read_to_end(&mut raw_bytes).await
|
||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||
let content = decode_bytes_to_string(&raw_bytes)
|
||
.map_err(|e| anyhow::anyhow!("读取文件失败(解码): {}", e))?;
|
||
|
||
// 二进制检测
|
||
if content.contains('\0') {
|
||
anyhow::bail!("不支持二进制文件");
|
||
}
|
||
|
||
// L3: expected_hash 指纹校验(防外部修改,TD-260621-04 闭环:与 read_file 返回的 file_hash 同格式)
|
||
if let Some(expected) = args["expected_hash"].as_str() {
|
||
let current_hash = compute_file_hash(&file_meta);
|
||
if current_hash != expected {
|
||
anyhow::bail!(
|
||
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
|
||
expected, current_hash
|
||
);
|
||
}
|
||
}
|
||
|
||
// 三模式分派计算 new_content + match_count + warning
|
||
// old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处)
|
||
// replace_lines 模式:行号区间 splice(越界 Err)
|
||
// anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err)
|
||
let new_content: String;
|
||
let match_count: usize;
|
||
let warning: Option<String>;
|
||
|
||
if has_old_text {
|
||
let old_text = args["old_text"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
|
||
if old_text.is_empty() {
|
||
anyhow::bail!("old_text 不能为空");
|
||
}
|
||
if old_text == new_text {
|
||
return Ok(serde_json::json!({
|
||
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
|
||
}));
|
||
}
|
||
// L2: old_text 精确匹配(CAS 语义)
|
||
// 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
|
||
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
|
||
// 退回原提示,不误导。
|
||
if !content.contains(old_text) {
|
||
let similar = similar_line_fragments(&content, old_text, 3);
|
||
if similar.is_empty() {
|
||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||
}
|
||
anyhow::bail!(
|
||
"未找到目标文本,文件可能已被修改。相近片段供核对 old_text 是否精确(注意缩进/空格):\n{}",
|
||
similar.join("\n")
|
||
);
|
||
}
|
||
let mc = content.matches(old_text).count();
|
||
match_count = mc;
|
||
warning = if mc > 1 {
|
||
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
|
||
} else { None };
|
||
new_content = content.replacen(old_text, new_text, 1);
|
||
} else if has_replace_lines {
|
||
let rl = args.get("replace_lines")
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
|
||
let start = rl["start"].as_u64()
|
||
.ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize;
|
||
let end = rl["end"].as_u64()
|
||
.ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize;
|
||
new_content = apply_line_range(&content, start, end, new_text)?;
|
||
match_count = 1;
|
||
warning = None;
|
||
} else {
|
||
// has_anchor
|
||
let an = args.get("anchor")
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
|
||
let a_start = an["start"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?;
|
||
let a_end = an["end"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?;
|
||
let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?;
|
||
new_content = apply_line_range(&content, start_line, end_line, new_text)?;
|
||
match_count = 1;
|
||
warning = None;
|
||
}
|
||
|
||
// 写序列(_patch_guard 持锁中:backup → tmp write → rename → cleanup)
|
||
// .bak 备份
|
||
let bak = format!("{}.bak", path);
|
||
tokio::fs::copy(path, &bak).await
|
||
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
|
||
|
||
// 原子写: tmp → rename
|
||
let tmp = format!("{}.tmp-write", path);
|
||
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
|
||
let _ = tokio::fs::remove_file(&tmp).await;
|
||
let _ = tokio::fs::remove_file(&bak).await;
|
||
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
|
||
}
|
||
if let Err(e) = tokio::fs::rename(&tmp, path).await {
|
||
let _ = tokio::fs::remove_file(&tmp).await;
|
||
return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak));
|
||
}
|
||
// 成功:清理 .bak
|
||
let _ = tokio::fs::remove_file(&bak).await;
|
||
drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁)
|
||
|
||
let size_diff = new_content.len() as i64 - content.len() as i64;
|
||
// 生成 unified diff 供前端审批卡/审计留痕展示
|
||
// content(旧)与 new_content(新)作用域内可用,无需重读文件
|
||
let diff = generate_diff(&content, &new_content);
|
||
let mut result = serde_json::json!({
|
||
"path": path,
|
||
"changed": true,
|
||
"size_diff": size_diff,
|
||
"matches_found": match_count,
|
||
"diff": diff,
|
||
});
|
||
if let Some(w) = warning {
|
||
result["warning"] = serde_json::Value::String(w);
|
||
}
|
||
Ok(result)
|
||
}
|
||
);
|
||
|
||
// ── file_info (Low 只读) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"file_info",
|
||
"获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![("path", "string", true)]),
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let p = std::path::Path::new(path);
|
||
if !p.exists() {
|
||
return Ok(serde_json::json!({ "path": path, "exists": false }));
|
||
}
|
||
let metadata = tokio::fs::metadata(path).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||
let is_dir = metadata.is_dir();
|
||
let size = metadata.len();
|
||
let modified = metadata.modified()
|
||
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||
.map(|d| d.as_millis() as i64);
|
||
// is_binary: 流式读前 8KB 检测 \x00
|
||
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
|
||
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
|
||
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
|
||
// size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。
|
||
let is_binary = if !is_dir && size > 0 {
|
||
use tokio::io::AsyncReadExt;
|
||
let file = match tokio::fs::File::open(path).await {
|
||
Ok(f) => f,
|
||
Err(_) => return Ok(serde_json::json!({
|
||
"path": path, "exists": true, "size": size, "lines": serde_json::Value::Null,
|
||
"modified": modified, "is_binary": false, "is_dir": is_dir,
|
||
"error": "读取文件头失败"
|
||
})),
|
||
};
|
||
let mut reader = tokio::io::BufReader::new(file);
|
||
let mut sample = vec![0u8; 8192];
|
||
let n = reader.read(&mut sample).await.unwrap_or(0);
|
||
sample[..n].contains(&0x00)
|
||
} else { false };
|
||
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
|
||
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
|
||
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
|
||
} else { None };
|
||
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
|
||
}
|
||
);
|
||
|
||
// ── append_file (Medium 追加) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"append_file",
|
||
"向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
|
||
RiskLevel::Medium,
|
||
schema: object_schema(vec![("path", "string", true), ("content", "string", true)]),
|
||
args => {
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||
// 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||
if !snap.is_authorized(parent) {
|
||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||
}
|
||
tokio::fs::create_dir_all(parent).await
|
||
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
|
||
}
|
||
use tokio::io::AsyncWriteExt;
|
||
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
|
||
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
|
||
let bytes = content.as_bytes();
|
||
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
|
||
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
|
||
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
|
||
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
|
||
}
|
||
);
|
||
|
||
// ── delete_file (High, 双捕获 allowed_dirs + data_dir) ──
|
||
// declare_tool! 宏仅支持单 capture。delete_file 同时需 allowed_dirs + data_dir,
|
||
// 故打包为 delete_ctx: Arc<(Arc<RwLock<AllowedDirs>>, PathBuf)> 单 capture,
|
||
// handler 首行解包为 (allowed_dirs, data_dir),其余 body 逐字照搬(逻辑零变更)。
|
||
let delete_ctx = Arc::new((allowed_dirs.clone(), data_dir.clone()));
|
||
declare_tool!(
|
||
registry,
|
||
delete_ctx: Arc<(Arc<RwLock<AllowedDirs>>, std::path::PathBuf)>,
|
||
"delete_file",
|
||
"删除文件。默认(permanent=false)软删除:移到工作区 .trash/ 目录保留可恢复;permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险,须人工批准",
|
||
RiskLevel::High,
|
||
schema: object_schema(vec![
|
||
("path", "string", true),
|
||
("permanent", "boolean", false),
|
||
]),
|
||
args => {
|
||
// 单 capture 宏适配:解包 delete_ctx 取 (allowed_dirs, data_dir),其余 body 逐字照搬
|
||
let allowed_dirs = &delete_ctx.0;
|
||
let data_dir = &delete_ctx.1;
|
||
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(
|
||
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
let target = std::path::Path::new(path);
|
||
if !target.exists() {
|
||
anyhow::bail!("文件不存在: {}", path);
|
||
}
|
||
// 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
|
||
let metadata = tokio::fs::metadata(target).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
|
||
if metadata.is_dir() {
|
||
anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path);
|
||
}
|
||
let file_name = target.file_name()
|
||
.ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
|
||
.to_string_lossy().to_string();
|
||
|
||
if permanent {
|
||
// 硬删除:不可逆,对齐 delete_project purge 语义
|
||
tokio::fs::remove_file(path).await
|
||
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
|
||
Ok(serde_json::json!({
|
||
"path": path,
|
||
"deleted": true,
|
||
"permanent": true,
|
||
"backed_up": false,
|
||
}))
|
||
} else {
|
||
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
|
||
// .trash 放 app_data_dir 下,不依赖编译期 workspace_root
|
||
let trash_dir = data_dir.join(".trash");
|
||
tokio::fs::create_dir_all(&trash_dir).await
|
||
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
|
||
let backup_name = format!("{}-{}", new_id(), file_name);
|
||
let backup_path = trash_dir.join(&backup_name);
|
||
let backup_path_str = backup_path.to_string_lossy().to_string();
|
||
// 软删除跨盘降级同盘 rename 原子;跨盘(Windows EXDEV os error 17,
|
||
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
|
||
// 失败回滚删 backup 保源完整)。
|
||
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
|
||
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
|
||
// (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。
|
||
// .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。
|
||
// helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。
|
||
if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await {
|
||
anyhow::bail!("移入回收站失败({})", e);
|
||
}
|
||
Ok(serde_json::json!({
|
||
"path": path,
|
||
"deleted": true,
|
||
"permanent": false,
|
||
"backed_up": true,
|
||
"backup_path": backup_path_str,
|
||
}))
|
||
}
|
||
}
|
||
);
|
||
|
||
// ── rename_file (Medium 重命名/移动) ──
|
||
// 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
|
||
// 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"rename_file",
|
||
"重命名或移动文件(同一工具覆盖 rename 和 move)。参数:from(源路径)+ to(目标路径)+ overwrite(默认 false,目标存在则拒绝)。同卷走 tokio::fs::rename(原子),跨卷自动降级 copy+remove(非原子,失败回滚删 to 保 from 完整)。须人工批准",
|
||
RiskLevel::Medium,
|
||
schema: object_schema(vec![
|
||
("from", "string", true),
|
||
("to", "string", true),
|
||
("overwrite", "boolean", false),
|
||
]),
|
||
args => {
|
||
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed,
|
||
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let from_resolved = resolve_workspace_path_with_allowed(
|
||
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
|
||
let to_resolved = resolve_workspace_path_with_allowed(
|
||
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
|
||
&snap,
|
||
)?;
|
||
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
|
||
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
|
||
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
|
||
let from_target = std::path::Path::new(from_path);
|
||
if !from_target.exists() {
|
||
anyhow::bail!("源文件不存在: {}", from_path);
|
||
}
|
||
let from_metadata = tokio::fs::metadata(from_target).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
|
||
if from_metadata.is_dir() {
|
||
anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path);
|
||
}
|
||
let bytes_moved = from_metadata.len();
|
||
|
||
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
||
let to_target = std::path::Path::new(to_path);
|
||
if let Some(parent) = to_target.parent() {
|
||
// 用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||
if !snap.is_authorized(parent) {
|
||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||
}
|
||
tokio::fs::create_dir_all(parent).await
|
||
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
|
||
}
|
||
|
||
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
|
||
if to_target.exists() {
|
||
if !overwrite {
|
||
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
|
||
}
|
||
tokio::fs::remove_file(to_path).await
|
||
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
|
||
}
|
||
|
||
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
|
||
// 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级),
|
||
// 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。
|
||
let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?;
|
||
Ok(serde_json::json!({
|
||
"from": from_path,
|
||
"to": to_path,
|
||
"renamed": true,
|
||
"bytes_moved": bytes_moved,
|
||
"cross_volume": cross_volume,
|
||
}))
|
||
}
|
||
);
|
||
|
||
// ── grep (Low 跨文件内容搜索) ──
|
||
// 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。
|
||
let grep_schema = {
|
||
let mut props = serde_json::Map::new();
|
||
props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" }));
|
||
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" }));
|
||
props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" }));
|
||
props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] }));
|
||
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
|
||
props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" }));
|
||
props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 }));
|
||
props.insert("context_chars".into(), serde_json::json!({ "type": "integer", "description": "字符级窗口(content 模式,大单行>阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 即不截)。与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级截窗口", "minimum": 0, "maximum": 2000 }));
|
||
props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 }));
|
||
serde_json::json!({
|
||
"type": "object",
|
||
"properties": props,
|
||
"required": ["pattern"],
|
||
})
|
||
};
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"grep",
|
||
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||
RiskLevel::Low,
|
||
schema: grep_schema,
|
||
args => {
|
||
let p = args["path"].as_str().unwrap_or("");
|
||
if p.is_empty() {
|
||
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
|
||
}
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
|
||
let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let pattern = args["pattern"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||
if pattern.is_empty() {
|
||
anyhow::bail!("pattern 不能为空");
|
||
}
|
||
let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
|
||
let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false);
|
||
let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
|
||
let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize;
|
||
// context_chars:字符级窗口(同长行内截匹配位置 ±N 字符,治 minified JS/CSS 大单行爆 prompt)。
|
||
// 与 -C 互补:-C 行级前后 N 行;context_chars 行内字符级。默认 0 即不截(context_chars=0 照旧返回整行)。
|
||
const LARGE_LINE_THRESHOLD: usize = 200;
|
||
let context_chars = args.get("context_chars").and_then(|v| v.as_u64()).unwrap_or(0).min(2000) as usize;
|
||
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
|
||
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
|
||
|
||
// 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹)
|
||
let mut re_builder = regex::RegexBuilder::new(pattern);
|
||
re_builder.case_insensitive(case_insensitive);
|
||
let (re, _auto_fixed) = match re_builder.build() {
|
||
Ok(r) => (r, false),
|
||
Err(e) => {
|
||
// LLM 常传 `a|b|c` 未分组模式(如 `<template|<div`),尝试 (?:...) 包裹后重试
|
||
let wrapped = format!("(?:{})", pattern);
|
||
let mut fix_builder = regex::RegexBuilder::new(&wrapped);
|
||
fix_builder.case_insensitive(case_insensitive);
|
||
match fix_builder.build() {
|
||
Ok(r) => (r, true),
|
||
Err(_) => anyhow::bail!(
|
||
"正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组",
|
||
pattern, e
|
||
),
|
||
}
|
||
}
|
||
};
|
||
|
||
// glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。
|
||
// 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。
|
||
let glob_re = match glob_opt {
|
||
Some(g) => Some(compile_glob_to_regex(g)
|
||
.map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?),
|
||
None => None,
|
||
};
|
||
|
||
// 递归遍历+逐文件读+行匹配,收集结果
|
||
let mut matches_out: Vec<FileGrepHit> = Vec::new();
|
||
let mut total: usize = 0;
|
||
let mut truncated = false;
|
||
// path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
|
||
// 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
|
||
// 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
|
||
let root_meta = tokio::fs::metadata(root).await
|
||
.map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?;
|
||
if root_meta.is_file() {
|
||
let file_name = std::path::Path::new(root)
|
||
.file_name()
|
||
.map(|n| n.to_string_lossy().into_owned())
|
||
.unwrap_or_default();
|
||
grep_one_file(
|
||
std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode,
|
||
context_lines, max_results, &mut matches_out, &mut total, &mut truncated,
|
||
).await?;
|
||
} else {
|
||
grep_recursive(
|
||
root, &re, glob_re.as_ref(), output_mode,
|
||
context_lines, max_results, 0, 6,
|
||
&mut matches_out, &mut total, &mut truncated,
|
||
).await?;
|
||
}
|
||
|
||
// output_mode 分派返回结构
|
||
let result = match output_mode {
|
||
"files_with_matches" => {
|
||
// 仅返命中文件路径列表(去重,顺序保留首次命中)
|
||
let files: Vec<String> = matches_out.iter()
|
||
.map(|h| h.file.clone())
|
||
.collect();
|
||
serde_json::json!({
|
||
"path": root,
|
||
"pattern": pattern,
|
||
"output_mode": output_mode,
|
||
"files": files,
|
||
"total": files.len(),
|
||
"truncated": truncated,
|
||
})
|
||
}
|
||
"count" => {
|
||
// 每文件命中行数
|
||
let counts: Vec<serde_json::Value> = matches_out.iter()
|
||
.map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() }))
|
||
.collect();
|
||
let total_files = counts.len();
|
||
serde_json::json!({
|
||
"path": root,
|
||
"pattern": pattern,
|
||
"output_mode": output_mode,
|
||
"counts": counts,
|
||
"total": total,
|
||
"total_files": total_files,
|
||
"truncated": truncated,
|
||
})
|
||
}
|
||
_ => {
|
||
// content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}]
|
||
// context_chars 字符级窗口(handler 层,不动 grep_one_file/grep_recursive):
|
||
// 大单行(minified JS/CSS)lm.content 是整行,可能上万字符撑爆 prompt。
|
||
// 若 context_chars>0 且该行长度 > 阈值 → 截取首个匹配位置 ±context_chars 字符窗口,
|
||
// 替换 content 并加 "…(±N 字符窗口)…" 标记。-C 行级上下文不受影响(整行 context 仍按行原样)。
|
||
let mut lines: Vec<serde_json::Value> = Vec::new();
|
||
for hit in &matches_out {
|
||
for lm in &hit.line_matches {
|
||
let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null };
|
||
// 字符级窗口:content_chars>0 且行超阈值 → 截窗口(content_field 替换整行为窗口)
|
||
let content_field: serde_json::Value = if context_chars > 0
|
||
&& lm.content.chars().count() > LARGE_LINE_THRESHOLD
|
||
{
|
||
let line_str = lm.content.as_str();
|
||
// 找首个匹配位置(re.find):window 中心,未匹配到(content 来自非正则路径)时行首
|
||
let match_byte = re.find(line_str)
|
||
.map(|m| m.start())
|
||
.unwrap_or(0);
|
||
let char_start = line_str[..match_byte.min(line_str.len())].chars().count();
|
||
let total_chars = line_str.chars().count();
|
||
let win_start = char_start.saturating_sub(context_chars);
|
||
let win_end = (char_start + context_chars).min(total_chars);
|
||
let window: String = line_str.chars().skip(win_start).take(win_end - win_start).collect();
|
||
serde_json::Value::String(format!(
|
||
"{}…(±{}字符窗口)…{}",
|
||
if win_start > 0 { "…" } else { "" },
|
||
context_chars,
|
||
window
|
||
))
|
||
} else {
|
||
serde_json::Value::String(lm.content.clone())
|
||
};
|
||
let mut entry = serde_json::json!({
|
||
"file": hit.file,
|
||
"line": line_no,
|
||
"content": content_field,
|
||
});
|
||
if context_lines > 0 && !lm.context.is_empty() {
|
||
entry["context"] = serde_json::Value::String(lm.context.clone());
|
||
}
|
||
lines.push(entry);
|
||
}
|
||
}
|
||
serde_json::json!({
|
||
"path": root,
|
||
"pattern": pattern,
|
||
"output_mode": "content",
|
||
"matches": lines,
|
||
"total": total,
|
||
"truncated": truncated,
|
||
})
|
||
}
|
||
};
|
||
Ok(result)
|
||
}
|
||
);
|
||
|
||
// ── search_files (Low 文件名搜索) ──
|
||
declare_tool!(
|
||
registry,
|
||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
"search_files",
|
||
"在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
|
||
args => {
|
||
let p = args["path"].as_str().unwrap_or("");
|
||
if p.is_empty() {
|
||
return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录,或直接提供绝对路径。"}));
|
||
}
|
||
let snap = allowed_dirs.read().await.clone();
|
||
let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
|
||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
|
||
let recursive = args["recursive"].as_bool().unwrap_or(false);
|
||
let pattern_lower = pattern.to_lowercase();
|
||
const MAX_RESULTS: usize = 50;
|
||
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
|
||
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
|
||
// 先收集全部结果用于 total 计数,再做 skip/take 分页
|
||
let mut all_results = Vec::new();
|
||
let mut total = 0u64;
|
||
// 用较大上限收集全量(分页由内存 skip/take 控制)
|
||
search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
|
||
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
|
||
let has_more = (offset + page_results.len()) < total as usize;
|
||
let mut out = serde_json::json!({
|
||
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
|
||
});
|
||
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
|
||
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
|
||
if total == 0 {
|
||
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
|
||
}
|
||
Ok(out)
|
||
}
|
||
);
|
||
|
||
// ── run_command (High 零捕获, Arc<()> 占位 capture) ──
|
||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs(放开目录让 AI 在用户任意项目目录闭环)。
|
||
// declare_tool! 宏需 capture 形参,用 Arc<()> 占位(handler body 不引用它)。
|
||
let dummy: Arc<()> = Arc::new(());
|
||
declare_tool!(
|
||
registry,
|
||
dummy: Arc<()>,
|
||
"run_command",
|
||
"在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。返回含 succeeded 布尔(按 exit_code==0 判定)。注意:部分命令非零退出属正常语义(如 git diff 无差异返回 1),此时以 stdout/stderr 内容为准,勿仅凭 exit_code≠0 判失败。",
|
||
RiskLevel::High,
|
||
schema: object_schema(vec![
|
||
("command", "string", true),
|
||
("working_dir", "string", false),
|
||
("timeout_secs", "integer", false),
|
||
]),
|
||
args => {
|
||
// BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致
|
||
// JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str()
|
||
// 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸
|
||
// 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。
|
||
let command = args["command"].as_str()
|
||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
|
||
.to_string();
|
||
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
|
||
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
|
||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||
Some(d) => {
|
||
validate_path(d)?;
|
||
d.to_string()
|
||
}
|
||
None => String::new(),
|
||
};
|
||
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
||
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
||
|
||
let request = ShellRequest {
|
||
command: command.clone(),
|
||
// 空字符串 → None(空路径是非法 current_dir,Windows 报 os error 123)
|
||
working_dir: if working_dir.is_empty() { None } else { Some(working_dir.clone()) },
|
||
env: HashMap::new(),
|
||
timeout_secs: Some(timeout_secs),
|
||
shell_type: Default::default(),
|
||
};
|
||
// 超时标注——execute 超时返 Err("命令执行超时: N秒")。
|
||
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
|
||
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
|
||
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
|
||
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
|
||
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
|
||
//
|
||
// 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`),
|
||
// LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、
|
||
// 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。
|
||
let shell_hint = if cfg!(windows) {
|
||
"\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)"
|
||
} else {
|
||
""
|
||
};
|
||
let result = execute_streaming(request, |kind: StreamKind, line: &str| {
|
||
// 实时流式 emit:每读一行 stdout/stderr 即 emit AiCommandOutput。
|
||
// task-local 未注入(command_stream::scope 未调用)→ emit_output 静默 noop,
|
||
// 不阻断命令(等价原 execute 一次性返回,无副作用)。
|
||
crate::commands::ai::command_stream::emit_output(kind, line);
|
||
}).await.map_err(|e| {
|
||
let msg = e.to_string();
|
||
if msg.contains("命令执行超时") {
|
||
anyhow::anyhow!(
|
||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}",
|
||
timeout_secs, shell_hint
|
||
)
|
||
} else {
|
||
anyhow::anyhow!("{}{}", msg, shell_hint)
|
||
}
|
||
})?;
|
||
|
||
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
|
||
const MAX_OUT: usize = 10_000;
|
||
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
|
||
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
|
||
|
||
Ok(serde_json::json!({
|
||
"succeeded": result.exit_code == Some(0),
|
||
"command": command,
|
||
"working_dir": working_dir,
|
||
"exit_code": result.exit_code,
|
||
"duration_ms": result.duration_ms,
|
||
"stdout": stdout,
|
||
"stderr": stderr,
|
||
"truncated": stdout_trunc || stderr_trunc,
|
||
}))
|
||
}
|
||
);
|
||
|
||
// ── detect_environment (Low 零捕获, Arc<()> 占位 capture, L1 agent 元能力层) ──
|
||
// 返回 JSON:{ os, default_shell, python_path, node_path }。
|
||
let dummy_env: Arc<()> = Arc::new(());
|
||
declare_tool!(
|
||
registry,
|
||
dummy_env: Arc<()>,
|
||
"detect_environment",
|
||
"探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
|
||
RiskLevel::Low,
|
||
schema: object_schema(vec![]),
|
||
args => {
|
||
// 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
|
||
let os = if cfg!(target_os = "windows") {
|
||
"Windows"
|
||
} else if cfg!(target_os = "macos") {
|
||
"macOS"
|
||
} else if cfg!(target_os = "linux") {
|
||
"Linux"
|
||
} else {
|
||
"Unknown"
|
||
};
|
||
// 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
|
||
// 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
|
||
// 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
|
||
let default_shell = if cfg!(target_os = "windows") {
|
||
"PowerShell"
|
||
} else {
|
||
"bash"
|
||
};
|
||
|
||
// 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
|
||
// 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
|
||
let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
|
||
Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
|
||
Err(_) => true, // 未设 = 默认开
|
||
};
|
||
|
||
let mut errors: Vec<String> = Vec::new();
|
||
let (python_path, node_path) = if probe_enabled {
|
||
// Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
|
||
// probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
|
||
// Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
|
||
let py = if cfg!(target_os = "windows") {
|
||
probe_executable("python", "where python", &mut errors).await
|
||
} else {
|
||
let p3 = probe_executable("python3", "which python3", &mut errors).await;
|
||
if p3.is_some() {
|
||
p3
|
||
} else {
|
||
// python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
|
||
probe_executable("python", "which python", &mut errors).await
|
||
}
|
||
};
|
||
let node = if cfg!(target_os = "windows") {
|
||
probe_executable("node", "where node", &mut errors).await
|
||
} else {
|
||
probe_executable("node", "which node", &mut errors).await
|
||
};
|
||
(py, node)
|
||
} else {
|
||
// 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
|
||
(None, None)
|
||
};
|
||
|
||
Ok(serde_json::json!({
|
||
"os": os,
|
||
"default_shell": default_shell,
|
||
"python_path": python_path,
|
||
"node_path": node_path,
|
||
"probe_enabled": probe_enabled,
|
||
"errors": errors,
|
||
}))
|
||
}
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// diff_files 辅助 — 读取文本文件内容(两文件对比共用)
|
||
// ============================================================
|
||
|
||
/// 读取文本文件内容,返回 (内容, 字节数)。
|
||
///
|
||
/// diff_files 两文件对比共用:1MB 上限 + 二进制拦截 + UTF-16 BOM 优先解码
|
||
/// (复用 decode_bytes_to_string,对齐 read_file/patch_file 的读取语义,单真相源)。
|
||
async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
|
||
use tokio::fs::File;
|
||
use tokio::io::AsyncReadExt;
|
||
let mut file = File::open(path).await
|
||
.map_err(|e| {
|
||
if e.kind() == std::io::ErrorKind::NotFound {
|
||
anyhow::anyhow!("文件不存在: {}", path)
|
||
} else {
|
||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||
}
|
||
})?;
|
||
let metadata = file.metadata().await
|
||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||
if metadata.len() > 1_048_576 {
|
||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||
}
|
||
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
|
||
file.read_to_end(&mut raw_bytes).await
|
||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||
let content = decode_bytes_to_string(&raw_bytes)
|
||
.map_err(|_| anyhow::anyhow!("文件非 UTF-8 文本(疑似二进制),无法对比: {}", path))?;
|
||
Ok((content, metadata.len()))
|
||
}
|
||
|
||
// ============================================================
|
||
// patch_file 失败提示辅助 — old_text 不匹配时给 LLM 相近锚点
|
||
// ============================================================
|
||
|
||
/// 在 `content` 中找出与 `needle` 最相近的若干行(行号+内容)。
|
||
///
|
||
/// 用途:patch_file 模式1 old_text 精确匹配失败时,把相近片段附进错误信息,让 LLM
|
||
/// 对照真实缩进/空格修正锚点,避免靠猜反复重试(AC-5 patch_file 27 次失败降频)。
|
||
///
|
||
/// 相似度算法:字符多重集 Dice 系数(2*公共字符数 / 两串字符数之和,0-100 整数百分比),
|
||
/// 仅对「首非空白字符相同」的行做评分(快速预筛,绝大多数行不相干直接跳过)。
|
||
/// `needle` 取首行 + 前 120 字符作探针(超长 old_text 罕见,同时约束扫描成本,
|
||
/// 1MB 文件全量逐行 O(n) 可接受)。阈值 40% 防误导性建议(完全无关文本不提示)。
|
||
fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String> {
|
||
// 探针取 needle 首行,截前 120 字符(评分用,兼顾精度与性能)
|
||
let first_line = needle.lines().next().unwrap_or(needle);
|
||
let mut probe = String::new();
|
||
for c in first_line.chars().take(120) {
|
||
probe.push(c);
|
||
}
|
||
if probe.trim().is_empty() {
|
||
return Vec::new();
|
||
}
|
||
|
||
// 预计算探针字符计数(行级评分复用,避免每行重建)
|
||
use std::collections::HashMap;
|
||
let mut probe_counts: HashMap<char, u32> = HashMap::new();
|
||
for c in probe.chars() {
|
||
*probe_counts.entry(c).or_insert(0) += 1;
|
||
}
|
||
let probe_len = probe.chars().count() as u32;
|
||
let probe_first = probe.trim_start().chars().next();
|
||
|
||
let mut scored: Vec<(u32, String)> = Vec::new();
|
||
for (idx, line) in content.lines().enumerate() {
|
||
let trimmed = line.trim_end();
|
||
if trimmed.is_empty() {
|
||
continue;
|
||
}
|
||
// 快速预筛:首非空白字符不同则跳过(大文件绝大多数行不相干)
|
||
match (probe_first, trimmed.trim_start().chars().next()) {
|
||
(Some(a), Some(b)) if a != b => continue,
|
||
_ => {}
|
||
}
|
||
let line_len = trimmed.chars().count() as u32;
|
||
if line_len == 0 {
|
||
continue;
|
||
}
|
||
// 公共字符计数(探针计数约束下扫描行字符)
|
||
let mut counts = probe_counts.clone();
|
||
let mut common = 0u32;
|
||
for c in trimmed.chars() {
|
||
if let Some(cnt) = counts.get_mut(&c) {
|
||
if *cnt > 0 {
|
||
*cnt -= 1;
|
||
common += 1;
|
||
}
|
||
}
|
||
}
|
||
// Dice = 2*common / (probe_len + line_len),乘 100 转百分比整数
|
||
let dice = (common * 200) / (probe_len + line_len).max(1);
|
||
if dice >= 40 {
|
||
let shown: String = if line.chars().count() > 100 {
|
||
line.chars().take(100).collect::<String>() + "…"
|
||
} else {
|
||
line.to_string()
|
||
};
|
||
scored.push((dice, format!("行 {}: {}", idx + 1, shown)));
|
||
}
|
||
}
|
||
|
||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||
scored.truncate(max);
|
||
scored.into_iter().map(|(_, s)| s).collect()
|
||
}
|
||
|
||
// ============================================================
|
||
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
|
||
// ============================================================
|
||
|
||
/// 父目录下与 needle 文件名最相近的 max 个文件名(Dice 字符多重集,思路同 similar_line_fragments)。
|
||
///
|
||
/// 用途:read_file/read_symbol 打开文件 NotFound 时,把父目录下最可能的文件名候选附进错误提示,
|
||
/// 治 LLM 拼错文件名/少写扩展名后盲目重试。低频错误路径(文件不存在),同步 std::fs::read_dir
|
||
/// 枚举可接受(目录通常不大)。阈值 40% 防误导(完全无关文件名不提示);父目录不可读/非目录返空。
|
||
fn similar_file_names(dir: &std::path::Path, needle: &str, max: usize) -> Vec<String> {
|
||
if needle.is_empty() {
|
||
return Vec::new();
|
||
}
|
||
// 预计算 needle 字符多重集计数(各文件名评分复用,同 similar_line_fragments 的探针计数)
|
||
use std::collections::HashMap;
|
||
let mut needle_counts: HashMap<char, u32> = HashMap::new();
|
||
for c in needle.chars() {
|
||
*needle_counts.entry(c).or_insert(0) += 1;
|
||
}
|
||
let needle_len = needle.chars().count() as u32;
|
||
|
||
let mut scored: Vec<(u32, String)> = Vec::new();
|
||
let entries = match std::fs::read_dir(dir) {
|
||
Ok(iter) => iter,
|
||
Err(_) => return Vec::new(), // 父目录不可读/非目录 → 无候选,退回原提示
|
||
};
|
||
for entry in entries.flatten() {
|
||
let name = entry.file_name().to_string_lossy().into_owned();
|
||
let name_len = name.chars().count() as u32;
|
||
if name == needle || name_len == 0 {
|
||
continue;
|
||
}
|
||
// 公共字符计数(needle 计数约束下扫描文件名)
|
||
let mut counts = needle_counts.clone();
|
||
let mut common = 0u32;
|
||
for c in name.chars() {
|
||
if let Some(cnt) = counts.get_mut(&c) {
|
||
if *cnt > 0 {
|
||
*cnt -= 1;
|
||
common += 1;
|
||
}
|
||
}
|
||
}
|
||
// Dice = 2*common / (needle_len + name_len),乘 100 转百分比整数(同 similar_line_fragments)
|
||
let dice = (common * 200) / (needle_len + name_len).max(1);
|
||
if dice >= 40 {
|
||
scored.push((dice, name));
|
||
}
|
||
}
|
||
|
||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||
scored.truncate(max);
|
||
scored.into_iter().map(|(_, s)| s).collect()
|
||
}
|
||
|
||
/// 文件打开 NotFound 的统一错误文案:基础提示 + 父目录相近文件名候选。
|
||
///
|
||
/// read_file / read_symbol 共用(DRY)。保留原「建议用 list_directory」引导;候选为空不追加。
|
||
fn file_not_found_error(path: &str) -> anyhow::Error {
|
||
let mut msg = format!(
|
||
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
||
path
|
||
);
|
||
let p = std::path::Path::new(path);
|
||
if let (Some(parent), Some(file_name)) = (p.parent(), p.file_name()) {
|
||
let needle = file_name.to_string_lossy().into_owned();
|
||
let candidates = similar_file_names(parent, &needle, 3);
|
||
if !candidates.is_empty() {
|
||
msg.push_str(&format!(
|
||
"。相近文件候选:\n{}\n(可核对实际文件名)",
|
||
candidates.join("\n")
|
||
));
|
||
}
|
||
}
|
||
anyhow::anyhow!(msg)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use regex::Regex;
|
||
|
||
// ============================================================
|
||
// (b) read_symbol / patch_file UTF-16 BOM 闭环测试
|
||
//
|
||
// 两 handler 改 read_to_end + decode_bytes_to_string 后,UTF-16 BOM 文件应正确解码为 UTF-8
|
||
// 字符串(而非 InvalidData 硬失败)。此处直接测 decode_bytes_to_string(两 handler 共用的
|
||
// 解码入口)在 BOM 各形态下的行为,证明 handler 走 Ok 分支而非 binary 回退 / bail。
|
||
// ============================================================
|
||
|
||
/// UTF-16 LE BOM 文件(PowerShell Out-File 默认产物)应被 decode_bytes_to_string 正确解码。
|
||
/// read_symbol/patch_file 旧 read_to_string 路径在此场景判 InvalidData 硬失败,
|
||
/// 改用 decode_bytes_to_string 后应返回正确内容(中文 + ASCII 混合)。
|
||
#[test]
|
||
fn test_decode_utf16_le_bom_reads_correctly() {
|
||
// 原文:含 ASCII + 中文,覆盖 PowerShell Out-File 编辑的典型内容
|
||
let original = "fn 核心逻辑() { return 42; }";
|
||
// 组装 UTF-16 LE BOM 字节流:FF FE + 每字符 LE u16
|
||
let mut bytes: Vec<u8> = vec![0xFF, 0xFE];
|
||
for unit in original.encode_utf16() {
|
||
bytes.extend_from_slice(&unit.to_le_bytes());
|
||
}
|
||
let decoded = decode_bytes_to_string(&bytes).expect("UTF-16 LE BOM 应解码成功");
|
||
assert_eq!(decoded, original, "解码后内容应与原文一致(中文不丢失/不错码)");
|
||
}
|
||
|
||
/// UTF-16 BE BOM(FE FF)同样应解码成功(对称性,decode_bytes_to_string 两端都支持)。
|
||
#[test]
|
||
fn test_decode_utf16_be_bom_reads_correctly() {
|
||
let original = "struct 符号 { x: i32 }";
|
||
let mut bytes: Vec<u8> = vec![0xFE, 0xFF];
|
||
for unit in original.encode_utf16() {
|
||
bytes.extend_from_slice(&unit.to_be_bytes());
|
||
}
|
||
let decoded = decode_bytes_to_string(&bytes).expect("UTF-16 BE BOM 应解码成功");
|
||
assert_eq!(decoded, original);
|
||
}
|
||
|
||
/// 真 UTF-8 无 BOM 文件(常态)不受影响——decode_bytes_to_string 回退 UTF-8 解码。
|
||
/// 证明改动未破现有 read_symbol/patch_file 对普通 UTF-8 文件的行为。
|
||
#[test]
|
||
fn test_decode_plain_utf8_no_bom_unchanged() {
|
||
let original = "const x = '普通 UTF-8 无 BOM';";
|
||
let bytes = original.as_bytes();
|
||
let decoded = decode_bytes_to_string(bytes).expect("UTF-8 无 BOM 应解码成功");
|
||
assert_eq!(decoded, original);
|
||
}
|
||
|
||
/// 真二进制(无 BOM 含 \0)应判 InvalidData,read_symbol 走 binary 回退 / patch_file bail。
|
||
/// 证明 decode_bytes_to_string 仍能识别真二进制(不误放行)。
|
||
#[test]
|
||
fn test_decode_real_binary_returns_invalid_data() {
|
||
let bytes = [0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x03]; // 含 \0 无 BOM
|
||
let result = decode_bytes_to_string(&bytes);
|
||
match result {
|
||
Err(e) => assert_eq!(
|
||
e.kind(),
|
||
std::io::ErrorKind::InvalidData,
|
||
"真二进制应返 InvalidData"
|
||
),
|
||
Ok(_) => panic!("真二进制不应解码成功"),
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// (a) grep context_chars 大单行 ±N 字符窗口测试
|
||
//
|
||
// context_chars 窗口逻辑嵌在 declare_tool! 宏内(handler body),无法直接单测。
|
||
// 此处抽 build_context_char_window 自由函数 1:1 镜像 handler 内的截窗口算法,
|
||
// 测试它覆盖:大单行截窗口/小单行不截/窗口边界 clamp/context_chars=0 不截。
|
||
// 算法与 handler 内字面一致,任何改动需同步(handler 注释已标注此函数名)。
|
||
// ============================================================
|
||
|
||
/// 字符级窗口算法(与 grep handler content 模式内字面 1:1 一致,handler 注释引用此函数名)。
|
||
/// 给定整行、匹配正则、context_chars、阈值;行 > 阈值且 context_chars>0 → 返回截窗口后的 content
|
||
/// 字段值(含 "…(±N字符窗口)…" 标记);否则返回整行原样。
|
||
fn build_context_char_window(
|
||
line: &str,
|
||
re: &Regex,
|
||
context_chars: usize,
|
||
threshold: usize,
|
||
) -> String {
|
||
if context_chars == 0 || line.chars().count() <= threshold {
|
||
return line.to_string();
|
||
}
|
||
let match_byte = re.find(line).map(|m| m.start()).unwrap_or(0);
|
||
let char_start = line[..match_byte.min(line.len())].chars().count();
|
||
let total_chars = line.chars().count();
|
||
let win_start = char_start.saturating_sub(context_chars);
|
||
let win_end = (char_start + context_chars).min(total_chars);
|
||
let window: String = line.chars().skip(win_start).take(win_end - win_start).collect();
|
||
// 结构:window 前可加前导 …(仅 win_start>0,match 行首不加),窗口标记附加在 window 后。
|
||
// 修复:原 format 在 window 前有字面量 …(±N字符窗口)…,致 match 行首(win_start=0)
|
||
// 也以 … 开头,违反「行首不前导 …」语义。
|
||
let lead = if win_start > 0 { "…" } else { "" };
|
||
format!(
|
||
"{lead}{window}…(±{context_chars}字符窗口)…",
|
||
lead = lead,
|
||
window = window,
|
||
context_chars = context_chars
|
||
)
|
||
}
|
||
|
||
/// 大单行 + context_chars>0 → 截匹配位置 ±N 字符窗口,minified JS 场景不爆 prompt。
|
||
#[test]
|
||
fn test_context_chars_window_large_line() {
|
||
// 模拟 minified JS:很长一行(>200 阈值),中间含 "function core"
|
||
let filler = "a".repeat(300);
|
||
let line = format!("{}function core(){{return 42;}}{}", filler, filler);
|
||
let re = Regex::new("function core").unwrap();
|
||
let windowed = build_context_char_window(&line, &re, 30, 200);
|
||
// 应含窗口标记,且长度远小于整行(整行 ~630 字符)
|
||
assert!(windowed.contains("(±30字符窗口)"), "应含窗口标记");
|
||
assert!(windowed.starts_with("…"), "match 前有内容应前导 …");
|
||
assert!(
|
||
windowed.chars().count() < line.chars().count(),
|
||
"窗口应短于整行"
|
||
);
|
||
// 窗口应含匹配核心文本(function core),±30 字符足够覆盖
|
||
assert!(windowed.contains("function core"));
|
||
}
|
||
|
||
/// context_chars=0 → 不截,返回整行原样(默认行为,不破现有 grep)。
|
||
#[test]
|
||
fn test_context_chars_zero_returns_full_line() {
|
||
let line = "a".repeat(500); // 大单行
|
||
let re = Regex::new("a").unwrap();
|
||
let result = build_context_char_window(&line, &re, 0, 200);
|
||
assert_eq!(result, line, "context_chars=0 应返回整行(默认行为不破)");
|
||
assert!(!result.contains("字符窗口"), "不应加窗口标记");
|
||
}
|
||
|
||
/// 行 ≤ 阈值(普通短行)→ 不截,即使 context_chars>0(避免短行被无谓截断)。
|
||
#[test]
|
||
fn test_context_chars_small_line_not_truncated() {
|
||
let line = "let x = 42; // 短行";
|
||
let re = Regex::new("x").unwrap();
|
||
let result = build_context_char_window(&line, &re, 100, 200);
|
||
assert_eq!(result, line, "行 ≤ 阈值应原样返回");
|
||
}
|
||
|
||
/// 匹配在行首 → 前导 … 不出现(win_start=0),仅后置窗口 + 标记。
|
||
#[test]
|
||
fn test_context_chars_match_at_line_start() {
|
||
let prefix = "";
|
||
let suffix = "b".repeat(300);
|
||
let line = format!("{}function core(){{}}{}", prefix, suffix);
|
||
let re = Regex::new("function core").unwrap();
|
||
let windowed = build_context_char_window(&line, &re, 20, 200);
|
||
assert!(!windowed.starts_with("…"), "match 在行首,win_start=0 不前导 …");
|
||
assert!(windowed.contains("(±20字符窗口)"));
|
||
}
|
||
|
||
// ============================================================
|
||
// similar_line_fragments — patch_file old_text 相近锚点提示
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn similar_fragments_finds_whitespace_drift_line() {
|
||
// 典型失败:old_text 缩进漂移(4 空格 vs 2 空格),字符重叠度高 → 应命中同一行
|
||
let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
|
||
let needle = " let x = 1;"; // 缩进从 2 变 4,内容同
|
||
let hits = similar_line_fragments(content, needle, 3);
|
||
assert!(
|
||
hits.iter().any(|h| h.contains("行 2")),
|
||
"缩进漂移应命中第 2 行,实际: {hits:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn similar_fragments_empty_when_no_relation() {
|
||
// 完全无关文本 → 空列表(退回原提示,不误导)
|
||
let content = "fn main() {\n let x = 1;\n}\n";
|
||
let hits = similar_line_fragments(content, "pub struct TotallyDifferent", 3);
|
||
assert!(hits.is_empty(), "无关文本不应给相近提示,实际: {hits:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn similar_fragments_multiline_needle_uses_first_line() {
|
||
// 多行 old_text:探针取首行,仍能定位到目标行
|
||
let content = "start\n let value = compute();\n println!(\"{}\", value);\nend\n";
|
||
let needle = " let value = compute();\n println!(\"{}\", value);\n"; // 首行同内容
|
||
let hits = similar_line_fragments(content, needle, 3);
|
||
assert!(
|
||
hits.iter().any(|h| h.contains("行 2")),
|
||
"多行 old_text 应命中首行所在行,实际: {hits:?}"
|
||
);
|
||
}
|
||
|
||
// ============================================================
|
||
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
|
||
// ============================================================
|
||
|
||
#[test]
|
||
fn similar_file_names_finds_close_name() {
|
||
// 临时目录放真实文件名,needle 少写一个字符(uilts.rs)应命中 utils.rs
|
||
let dir = std::env::temp_dir().join(format!("df-similar-files-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
for name in ["main.rs", "mod.rs", "utils.rs", "README.md"] {
|
||
std::fs::write(dir.join(name), "").unwrap();
|
||
}
|
||
let hits = similar_file_names(&dir, "uilts.rs", 3);
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
assert!(
|
||
hits.iter().any(|h| h == "utils.rs"),
|
||
"相近文件名应命中 utils.rs,实际: {hits:?}"
|
||
);
|
||
// max 截断
|
||
assert!(hits.len() <= 3, "候选数应不超过 max,实际: {}", hits.len());
|
||
}
|
||
|
||
#[test]
|
||
fn similar_file_names_empty_when_no_relation() {
|
||
let dir = std::env::temp_dir().join(format!("df-similar-empty-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("readme.md"), "").unwrap();
|
||
let hits = similar_file_names(&dir, "config.yaml", 3);
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
assert!(hits.is_empty(), "无关文件名不应给候选,实际: {hits:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn file_not_found_error_appends_candidates() {
|
||
// 少写一个字符的拼写 → 错误文案应含 target.rs 候选 + 保留 list_directory 引导
|
||
let dir = std::env::temp_dir().join(format!("df-notfound-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
std::fs::write(dir.join("target.rs"), "fn main() {}\n").unwrap();
|
||
let missing = dir.join("targett.rs");
|
||
let msg = file_not_found_error(&missing.to_string_lossy()).to_string();
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
assert!(msg.contains("路径不存在"), "应保留基础提示,实际: {msg}");
|
||
assert!(msg.contains("list_directory"), "应保留 list_directory 引导,实际: {msg}");
|
||
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
|
||
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
|
||
}
|
||
}
|
||
|
||
|
||
// probe_executable 仍在 super::tool_registry(detect_environment 原引用),迁移后该私有 fn
|
||
// 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。
|