Files
DevFlow/src-tauri/src/commands/ai/tools/file.rs
T

1385 lines
81 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 文件系统类 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(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
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 限下仍可能数万行)
// BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件,
// 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
// 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
let line_count = content.lines().count();
let (result, offset_used, has_more, 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 行(大文件翻页友好,避免一次灌入全量)。
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
const DEFAULT_PREVIEW_LINES: usize = 500;
let limit = args["limit"].as_u64()
.unwrap_or(DEFAULT_PREVIEW_LINES as u64)
.min(2000) as usize;
let page: Vec<&str> = content.lines().take(limit).collect();
let more = line_count > page.len();
(page.join("\n"), None, more, 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
// 支持语言集合内,则末尾追加一句引导提示改用 read_symbol 精确提取符号,避免读全文件
// 浪费上下文。复用 code_intel::is_supported_ext 单真相源(与 read_symbol handler 语言
// 集合一致,不重复定义)。仅首次读取(无 offset)追加,offset 分页续读不重复提示防噪音。
let content_field = if offset_used.is_none()
&& crate::commands::ai::code_intel::is_supported_ext(
path.rsplit('.').next().unwrap_or("").to_lowercase().as_str(),
)
{
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| 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,
))
}
);
// ── 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);
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
}
);
// ── 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 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(raw)
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
} else if encoding.eq_ignore_ascii_case("utf-8") {
raw.as_bytes().to_vec()
} else {
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding);
};
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if bytes.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
}
let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
Ok(m) if m.len() > 0 => {
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
Some(m.len())
}
Ok(_) => Some(0), // 空文件(无需备份)
Err(_) => None, // 不存在(新建)
};
if let Some(parent) = target.parent() {
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {}", e));
}
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) {
let bak = format!("{}.bak", path);
let _ = tokio::fs::remove_file(&bak).await;
}
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
if let Some(old) = old_size {
if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
}
);
// ── 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_textexpected_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 参数"))?;
// F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count();
if mode_count == 0 {
anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一");
}
if mode_count > 1 {
anyhow::bail!(
"模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)",
mode_count
);
}
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
let file_meta = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
if file_meta.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
}
// TD-260621-03:读改写整体锁内防 lost update。
// 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
// A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
// 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
// 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。
let _patch_guard = FILE_LOCKS.lock().await;
// 读文件内容 + 校验(锁内,纯读 + CPU 计算)
// 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
);
}
}
// F-260617-01: 三模式分派计算 new_content + match_count + warning
// old_text 模式:精确匹配(CAS)+ 多匹配警告(仅替换第 1 处)
// replace_lines 模式:行号区间 splice(越界 Err)
// anchor 模式:子串锚点定位 → 行号区间 splice(找不到/start 在 end 后 Err
let new_content: String;
let match_count: usize;
let warning: Option<String>;
if has_old_text {
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
if old_text.is_empty() {
anyhow::bail!("old_text 不能为空");
}
if old_text == new_text {
return Ok(serde_json::json!({
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
}
// L2: old_text 精确匹配(CAS 语义)
if !content.contains(old_text) {
anyhow::bail!("未找到目标文本,文件可能已被修改");
}
let mc = content.matches(old_text).count();
match_count = mc;
warning = if mc > 1 {
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
} else { None };
new_content = content.replacen(old_text, new_text, 1);
} else if has_replace_lines {
let rl = args.get("replace_lines")
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
let start = rl["start"].as_u64()
.ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize;
let end = rl["end"].as_u64()
.ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize;
new_content = apply_line_range(&content, start, end, new_text)?;
match_count = 1;
warning = None;
} else {
// has_anchor
let an = args.get("anchor")
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
let a_start = an["start"].as_str()
.ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?;
let a_end = an["end"].as_str()
.ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?;
let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?;
new_content = apply_line_range(&content, start_line, end_line, new_text)?;
match_count = 1;
warning = None;
}
// 写序列(_patch_guard 持锁中:backup → tmp write → rename → cleanup
// .bak 备份
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
// 原子写: tmp → rename
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
let _ = tokio::fs::remove_file(&tmp).await;
let _ = tokio::fs::remove_file(&bak).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak));
}
// 成功:清理 .bak
let _ = tokio::fs::remove_file(&bak).await;
drop(_patch_guard); // 释放锁(diff 计算纯 CPU,不需持锁)
let size_diff = new_content.len() as i64 - content.len() as i64;
// 生成 unified diff 供前端审批卡/审计留痕展示
// content(旧)与 new_content(新)作用域内可用,无需重读文件
let diff = generate_diff(&content, &new_content);
let mut result = serde_json::json!({
"path": path,
"changed": true,
"size_diff": size_diff,
"matches_found": match_count,
"diff": diff,
});
if let Some(w) = warning {
result["warning"] = serde_json::Value::String(w);
}
Ok(result)
}
);
// ── 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 (BUG-260617-02)
// 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
// >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
// 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
// size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。
let is_binary = if !is_dir && size > 0 {
use tokio::io::AsyncReadExt;
let file = match tokio::fs::File::open(path).await {
Ok(f) => f,
Err(_) => return Ok(serde_json::json!({
"path": path, "exists": true, "size": size, "lines": serde_json::Value::Null,
"modified": modified, "is_binary": false, "is_dir": is_dir,
"error": "读取文件头失败"
})),
};
let mut reader = tokio::io::BufReader::new(file);
let mut sample = vec![0u8; 8192];
let n = reader.read(&mut sample).await.unwrap_or(0);
sample[..n].contains(&0x00)
} else { false };
// lines: 文本文件 \n 计数(>2MB 跳过避免全量读)
let lines = if !is_dir && !is_binary && size <= 2_097_152 {
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
} else { None };
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
}
);
// ── 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() {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
use tokio::io::AsyncWriteExt;
let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
.map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
let bytes = content.as_bytes();
file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
}
);
// ── 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();
// 软删除跨盘降级(F-260621):同盘 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() {
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
if !snap.is_authorized(parent) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
}
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
if to_target.exists() {
if !overwrite {
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
}
tokio::fs::remove_file(to_path).await
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
}
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
// 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级),
// 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。
let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?;
Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": cross_volume,
}))
}
);
// ── grep (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;
// BUG-260625-01: 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;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
}
);
// ── 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(),
};
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
//
// 任务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,
}))
}
);
}
#[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字符窗口)"));
}
}
// probe_executable 仍在 super::tool_registry(detect_environment 原引用),迁移后该私有 fn
// 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。