新增: patch_file支持行号区间和锚点定位

This commit is contained in:
2026-06-17 00:33:49 +08:00
parent 2ddfea67c7
commit 2446d1b876

View File

@@ -115,6 +115,107 @@ fn truncate_output(s: &str, max: usize) -> (String, bool) {
)
}
// ============================================================
// patch_file 三模式共用底层F-260617-01
//
// 三模式互斥old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点),
// 后两者均归约为「按行号区间 [start, end]1-based 含首尾)替换为 new_text」
// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。
// 提取为纯函数便于直接单测(不依赖 async/fs/锁handler 闭包内复用。
// ============================================================
/// 按行号区间替换:将 content 第 [start, end] 行1-based, 含首尾)替换为 new_text。
///
/// - 与 search_files 一致用 `\n` 切分;末尾尾随 `\n` 保留(避免误删文件末换行)。
/// - 行号 1-based含首尾replace_lines(2,3) 替换第 2、3 两行。
/// - 越界start<1 或 end>lines.len() 或 start>end返 Err。
/// - new_text 可为多行(含 `\n`),整体作为替换块原样插入。
pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text: &str) -> anyhow::Result<String> {
if start < 1 {
anyhow::bail!("start 行号必须 >= 1got {}", start);
}
if start > end {
anyhow::bail!("start({}) > end({})", start, end);
}
// 末尾尾随换行的文件lines() 会丢掉末尾空段,用 split 保留语义更稳,
// 但为与 search_files/read_file 行计数一致,这里用 lines() 并单独处理尾换行。
let trailing_newline = content.ends_with('\n');
let lines: Vec<&str> = content.lines().collect();
if end > lines.len() {
anyhow::bail!("end 行号 {} 超出文件总行数 {}1-based 含尾)", end, lines.len());
}
// splice: 前 start-1 行 + new_text + 第 end 行之后
let mut out = String::new();
for (i, line) in lines.iter().enumerate() {
let lineno = i + 1;
if lineno < start {
out.push_str(line);
out.push('\n');
} else if lineno == start {
out.push_str(new_text);
// 若 new_text 未以换行结尾且其后还有保留行,需补一个换行分隔
if !new_text.ends_with('\n') {
out.push('\n');
}
} else if lineno > end {
out.push_str(line);
out.push('\n');
}
// start < lineno <= end 的行被丢弃(落入区间内)
}
// 还原尾换行语义:原文件以 \n 结尾则补回splice 循环每行均加 \n
// 仅当 new_text 区间恰好覆盖到文件末尾时可能少一个尾换行,统一处理)
// 注意:若 new_text 以 \n 结尾splice 已带尾换行;否则原文件尾换行由末行 push('\n') 提供。
// 仅当文件完全不以换行结尾时,需去掉末尾多余 \n。
if !trailing_newline && out.ends_with('\n') {
out.pop();
}
Ok(out)
}
/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。
///
/// - 子串匹配(大小写敏感,与 search_files 一致)。
/// - start/end 任一找不到返 Errstart 行号 > end 行号返 Err即 end 必须在 start 之后或同行)。
/// - 同一行同时命中 start/endstart==end区间退化为单行替换。
/// - 返回 (start_line, end_line)1-based 含首尾)。
pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> {
if start.is_empty() {
anyhow::bail!("anchor.start 不能为空");
}
if end.is_empty() {
anyhow::bail!("anchor.end 不能为空");
}
let mut start_line: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
if line.contains(start) {
start_line = Some(i + 1);
break;
}
}
let start_line = start_line
.ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?;
// end 在 start_line 之后(含 start_line 同行)第一个命中的行
let mut end_line: Option<usize> = None;
for (i, line) in content.lines().enumerate() {
let lineno = i + 1;
if lineno < start_line {
continue;
}
if line.contains(end) {
end_line = Some(lineno);
break;
}
}
let end_line = end_line
.ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", end, start_line))?;
// start_line <= end_line 已由搜索顺序保证,此处断言兜底
debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line);
Ok((start_line, end_line))
}
/// 全局文件锁表每个路径一把互斥锁L1 防护,防同文件并发读写冲突)
///
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
@@ -780,36 +881,69 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
);
// ── 局部文件编辑 (Medium risk) ──
// F-260617-01: 三模式互斥old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点)。
// object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。
// 三模式均需 path + new_textold_text/replace_lines/anchor 三选一互斥expected_hash 可选通用。
let patch_file_schema = {
let mut props = serde_json::Map::new();
props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" }));
props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" }));
props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" }));
props.insert("replace_lines".into(), serde_json::json!({
"type": "object",
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移",
"properties": {
"start": { "type": "integer", "description": "起始行号1-based" },
"end": { "type": "integer", "description": "结束行号1-based" }
},
"required": ["start", "end"]
}));
props.insert("anchor".into(), serde_json::json!({
"type": "object",
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
"properties": {
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
},
"required": ["start", "end"]
}));
props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "old_text 模式)行号辅助定位,可选" }));
props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹read_file 返回的 file_hash三模式通用防并发修改" }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["path", "new_text"],
})
};
registry.register(
"patch_file", "局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。每个补丁指定 old_text要替换的原文必须精确匹配含空格/缩进)和 new_text新内容。可选 line 辅助定位。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("old_text", "string", true),
("new_text", "string", true),
("line", "integer", false),
("expected_hash", "string", false),
]),
"patch_file", "局部更新文件内容三模式互斥。模式1 old_text精确匹配原文替换含空格/缩进CAS 语义。模式2 replace_lines按行号区间 {start,end}1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_textexpected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
patch_file_schema,
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 old_text 参数"))?;
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// 边界校验
if old_text.is_empty() {
anyhow::bail!("old_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 old_text == new_text {
return Ok(serde_json::json!({
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
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);
@@ -847,20 +981,59 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}
}
// L2: old_text 精确匹配CAS 语义)
if !content.contains(old_text) {
anyhow::bail!("未找到目标文本,文件可能已被修改");
// 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;
}
// 多匹配检测
let match_count = content.matches(old_text).count();
let warning = if match_count > 1 {
Some(format!("匹配到 {} 处,仅替换第 1 处", match_count))
} else { None };
// 执行替换(仅替换第 1 处)
let new_content = content.replacen(old_text, new_text, 1);
// 阶段二L1 tokio::Mutex 保护写序列backup → tmp write → rename → cleanup
// tokio::sync::Mutex 的 MutexGuard 是 Send可安全跨 await
let abs_path = target.canonicalize()
@@ -1516,4 +1689,166 @@ mod tests {
fs::remove_dir_all(&tmp).ok();
}
// ============================================================
// patch_file 三模式共用底层测试F-260617-01
// 纯函数 apply_line_range / resolve_anchor_to_lines不依赖 fs/async/锁。
// handler 闭包内的三模式分派/互斥校验逻辑由参数解析+这两个函数组合而成,
// 故覆盖纯函数即覆盖 replace_lines/anchor 两模式核心old_text 模式逻辑
// 沿用既有实现contains + replacen未改零回归不在此重复。
// ============================================================
/// apply_line_range: 区间替换含首尾行1-based
#[test]
fn test_apply_line_range_inclusive() {
// 3 行文件(末尾无尾换行)
let content = "line1\nline2\nline3";
let out = apply_line_range(content, 2, 3, "REPLACED").unwrap();
// 第 2、3 行被替换为 REPLACEDREPLACED 不以 \n 结尾且其后无保留行 → 不补分隔 \n
assert_eq!(out, "line1\nREPLACED");
}
/// apply_line_range: 区间仅替换中间行,保留前后行
#[test]
fn test_apply_line_range_middle() {
let content = "a\nb\nc\nd\ne";
let out = apply_line_range(content, 2, 4, "X\nY").unwrap();
// 替换 2-4 行(b,c,d)为 X\nYY 后还有 e需补 \n 分隔
assert_eq!(out, "a\nX\nY\ne");
}
/// apply_line_range: 区间替换到文件末尾,保留尾换行语义
#[test]
fn test_apply_line_range_tail_newline_preserved() {
// 文件以 \n 结尾
let content = "a\nb\nc\n";
let out = apply_line_range(content, 2, 3, "NEW").unwrap();
// 替换 b,c 两行为 NEW原文件尾随 \nNEW 不带 \n其后无保留行
// splice 循环对 NEW作为 start 行push 后因不以 \n 结尾且无后续保留行 → 不补;
// 但原文件 trailing_newline=true末尾应保留 \n
assert_eq!(out, "a\nNEW\n");
}
/// apply_line_range: 单行区间start==end
#[test]
fn test_apply_line_range_single_line() {
let content = "a\nb\nc";
let out = apply_line_range(content, 2, 2, "X").unwrap();
assert_eq!(out, "a\nX\nc");
}
/// apply_line_range: 多行 new_text 原样插入
#[test]
fn test_apply_line_range_multiline_newtext() {
let content = "h\nOLD\nt";
let out = apply_line_range(content, 2, 2, "n1\nn2\nn3").unwrap();
assert_eq!(out, "h\nn1\nn2\nn3\nt");
}
/// apply_line_range: start<1 越界 Err
#[test]
fn test_apply_line_range_start_below_one() {
let content = "a\nb";
let err = apply_line_range(content, 0, 1, "X").unwrap_err();
assert!(format!("{}", err).contains("start 行号必须 >= 1"), "got: {}", err);
}
/// apply_line_range: end>lines.len() 越界 Err
#[test]
fn test_apply_line_range_end_overflow() {
let content = "a\nb"; // 2 行
let err = apply_line_range(content, 1, 5, "X").unwrap_err();
assert!(format!("{}", err).contains("end 行号"), "got: {}", err);
}
/// apply_line_range: start>end Err
#[test]
fn test_apply_line_range_start_gt_end() {
let content = "a\nb\nc";
let err = apply_line_range(content, 3, 2, "X").unwrap_err();
assert!(format!("{}", err).contains("start"), "got: {}", err);
}
/// resolve_anchor_to_lines: start/end 子串命中(基本路径)
#[test]
fn test_resolve_anchor_basic() {
// 注意 end 锚取子串;此处用 END-FN 唯一标记第 4 行(避免 println! 行内 {} 干扰)
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n} // END-FN\n";
// start 锚 "fn foo" 在第 1 行end 锚 "END-FN" 在第 4 行
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "END-FN").unwrap();
assert_eq!((s, e), (1, 4));
}
/// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行
/// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」
#[test]
fn test_resolve_anchor_substring_matches_inside_line() {
// end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 }
let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n";
let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap();
assert_eq!((s, e), (1, 3));
}
/// resolve_anchor_to_lines: 同行命中start==end单行区间
#[test]
fn test_resolve_anchor_same_line() {
let content = "header marker end here\nother";
// start/end 均在第 1 行命中
let (s, e) = resolve_anchor_to_lines(content, "header", "end").unwrap();
assert_eq!((s, e), (1, 1));
}
/// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串)
#[test]
fn test_resolve_anchor_picks_first_end_after_start() {
// 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行
let content = "START\n}\nx\n}";
let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap();
assert_eq!((s, e), (1, 2));
}
/// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致)
#[test]
fn test_resolve_anchor_case_sensitive() {
let content = "Hello\nWorld";
// 小写 "hello" 不命中第 1 行的大写 "Hello" → Err
let err = resolve_anchor_to_lines(content, "hello", "World").unwrap_err();
assert!(format!("{}", err).contains("anchor.start 子串"), "got: {}", err);
}
/// resolve_anchor_to_lines: start 子串找不到 Err
#[test]
fn test_resolve_anchor_start_not_found() {
let content = "a\nb\nc";
let err = resolve_anchor_to_lines(content, "NOPE", "c").unwrap_err();
assert!(format!("{}", err).contains("anchor.start 子串「NOPE」"), "got: {}", err);
}
/// resolve_anchor_to_lines: end 子串在 start 之后找不到 Err
#[test]
fn test_resolve_anchor_end_not_found_after_start() {
// start 命中第 1 行,但 end 子串 "z" 在第 1 行之后不存在
let content = "START\nb\nc";
let err = resolve_anchor_to_lines(content, "START", "z").unwrap_err();
assert!(format!("{}", err).contains("anchor.end 子串「z」"), "got: {}", err);
}
/// resolve_anchor_to_lines: start/end 空串 Err
#[test]
fn test_resolve_anchor_empty_marker() {
let content = "a\nb";
assert!(resolve_anchor_to_lines(content, "", "b").is_err());
assert!(resolve_anchor_to_lines(content, "a", "").is_err());
}
/// 三模式组合案例anchor 解析为行号 → apply_line_range 执行替换(端到端纯函数链)
#[test]
fn test_anchor_then_apply_line_range_e2e() {
let content = "use std::fs;\nuse std::io;\n\nfn main() {}\n";
// 锚点定位 import 区块(第 1-2 行),替换为新 import
let (s, e) = resolve_anchor_to_lines(content, "use std::fs", "use std::io").unwrap();
assert_eq!((s, e), (1, 2));
let out = apply_line_range(content, s, e, "use std::path;").unwrap();
assert_eq!(out, "use std::path;\n\nfn main() {}\n");
}
}