diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index adb02c1..41dd956 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -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 { + if start < 1 { + anyhow::bail!("start 行号必须 >= 1(got {})", start); + } + if start > end { + anyhow::bail!("start({}) > end({})", start, end); + } + // 末尾尾随换行的文件:lines() 会丢掉末尾空段,用 split 保留语义更稳, + // 但为与 search_files/read_file 行计数一致,这里用 lines() 并单独处理尾换行。 + let trailing_newline = content.ends_with('\n'); + let lines: Vec<&str> = content.lines().collect(); + if end > lines.len() { + anyhow::bail!("end 行号 {} 超出文件总行数 {}(1-based 含尾)", end, lines.len()); + } + // splice: 前 start-1 行 + new_text + 第 end 行之后 + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + let lineno = i + 1; + if lineno < start { + out.push_str(line); + out.push('\n'); + } else if lineno == start { + out.push_str(new_text); + // 若 new_text 未以换行结尾且其后还有保留行,需补一个换行分隔 + if !new_text.ends_with('\n') { + out.push('\n'); + } + } else if lineno > end { + out.push_str(line); + out.push('\n'); + } + // start < lineno <= end 的行被丢弃(落入区间内) + } + // 还原尾换行语义:原文件以 \n 结尾则补回(splice 循环每行均加 \n, + // 仅当 new_text 区间恰好覆盖到文件末尾时可能少一个尾换行,统一处理) + // 注意:若 new_text 以 \n 结尾,splice 已带尾换行;否则原文件尾换行由末行 push('\n') 提供。 + // 仅当文件完全不以换行结尾时,需去掉末尾多余 \n。 + if !trailing_newline && out.ends_with('\n') { + out.pop(); + } + Ok(out) +} + +/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。 +/// +/// - 子串匹配(大小写敏感,与 search_files 一致)。 +/// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。 +/// - 同一行同时命中 start/end:start==end,区间退化为单行替换。 +/// - 返回 (start_line, end_line)(1-based 含首尾)。 +pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> { + if start.is_empty() { + anyhow::bail!("anchor.start 不能为空"); + } + if end.is_empty() { + anyhow::bail!("anchor.end 不能为空"); + } + let mut start_line: Option = 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 = 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) -> AiToolRegistry { ); // ── 局部文件编辑 (Medium risk) ── + // F-260617-01: 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点)。 + // object_schema 只支持扁平标量三元组,故 replace_lines/anchor 嵌套对象手工拼 schema。 + // 三模式均需 path + new_text;old_text/replace_lines/anchor 三选一(互斥);expected_hash 可选通用。 + let patch_file_schema = { + let mut props = serde_json::Map::new(); + props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" })); + props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" })); + props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" })); + props.insert("replace_lines".into(), serde_json::json!({ + "type": "object", + "description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移", + "properties": { + "start": { "type": "integer", "description": "起始行号(1-based,含)" }, + "end": { "type": "integer", "description": "结束行号(1-based,含)" } + }, + "required": ["start", "end"] + })); + props.insert("anchor".into(), serde_json::json!({ + "type": "object", + "description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文", + "properties": { + "start": { "type": "string", "description": "首行子串标记(大小写敏感)" }, + "end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" } + }, + "required": ["start", "end"] + })); + props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "(old_text 模式)行号辅助定位,可选" })); + props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹(read_file 返回的 file_hash),三模式通用,防并发修改" })); + serde_json::json!({ + "type": "object", + "properties": props, + "required": ["path", "new_text"], + }) + }; registry.register( - "patch_file", "局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。每个补丁指定 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_text,expected_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) -> 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; + + 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 行被替换为 REPLACED;REPLACED 不以 \n 结尾且其后无保留行 → 不补分隔 \n + assert_eq!(out, "line1\nREPLACED"); + } + + /// apply_line_range: 区间仅替换中间行,保留前后行 + #[test] + fn test_apply_line_range_middle() { + let content = "a\nb\nc\nd\ne"; + let out = apply_line_range(content, 2, 4, "X\nY").unwrap(); + // 替换 2-4 行(b,c,d)为 X\nY;Y 后还有 e,需补 \n 分隔 + assert_eq!(out, "a\nX\nY\ne"); + } + + /// apply_line_range: 区间替换到文件末尾,保留尾换行语义 + #[test] + fn test_apply_line_range_tail_newline_preserved() { + // 文件以 \n 结尾 + let content = "a\nb\nc\n"; + let out = apply_line_range(content, 2, 3, "NEW").unwrap(); + // 替换 b,c 两行为 NEW;原文件尾随 \n,NEW 不带 \n,其后无保留行 + // splice 循环对 NEW(作为 start 行)push 后因不以 \n 结尾且无后续保留行 → 不补; + // 但原文件 trailing_newline=true,末尾应保留 \n + assert_eq!(out, "a\nNEW\n"); + } + + /// apply_line_range: 单行区间(start==end) + #[test] + fn test_apply_line_range_single_line() { + let content = "a\nb\nc"; + let out = apply_line_range(content, 2, 2, "X").unwrap(); + assert_eq!(out, "a\nX\nc"); + } + + /// apply_line_range: 多行 new_text 原样插入 + #[test] + fn test_apply_line_range_multiline_newtext() { + let content = "h\nOLD\nt"; + let out = apply_line_range(content, 2, 2, "n1\nn2\nn3").unwrap(); + assert_eq!(out, "h\nn1\nn2\nn3\nt"); + } + + /// apply_line_range: start<1 越界 Err + #[test] + fn test_apply_line_range_start_below_one() { + let content = "a\nb"; + let err = apply_line_range(content, 0, 1, "X").unwrap_err(); + assert!(format!("{}", err).contains("start 行号必须 >= 1"), "got: {}", err); + } + + /// apply_line_range: end>lines.len() 越界 Err + #[test] + fn test_apply_line_range_end_overflow() { + let content = "a\nb"; // 2 行 + let err = apply_line_range(content, 1, 5, "X").unwrap_err(); + assert!(format!("{}", err).contains("end 行号"), "got: {}", err); + } + + /// apply_line_range: start>end Err + #[test] + fn test_apply_line_range_start_gt_end() { + let content = "a\nb\nc"; + let err = apply_line_range(content, 3, 2, "X").unwrap_err(); + assert!(format!("{}", err).contains("start"), "got: {}", err); + } + + /// resolve_anchor_to_lines: start/end 子串命中(基本路径) + #[test] + fn test_resolve_anchor_basic() { + // 注意 end 锚取子串;此处用 END-FN 唯一标记第 4 行(避免 println! 行内 {} 干扰) + let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n} // END-FN\n"; + // start 锚 "fn foo" 在第 1 行,end 锚 "END-FN" 在第 4 行 + let (s, e) = resolve_anchor_to_lines(content, "fn foo", "END-FN").unwrap(); + assert_eq!((s, e), (1, 4)); + } + + /// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行 + /// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」 + #[test] + fn test_resolve_anchor_substring_matches_inside_line() { + // end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 } + let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n"; + let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap(); + assert_eq!((s, e), (1, 3)); + } + + /// resolve_anchor_to_lines: 同行命中(start==end,单行区间) + #[test] + fn test_resolve_anchor_same_line() { + let content = "header marker end here\nother"; + // start/end 均在第 1 行命中 + let (s, e) = resolve_anchor_to_lines(content, "header", "end").unwrap(); + assert_eq!((s, e), (1, 1)); + } + + /// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串) + #[test] + fn test_resolve_anchor_picks_first_end_after_start() { + // 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行 + let content = "START\n}\nx\n}"; + let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap(); + assert_eq!((s, e), (1, 2)); + } + + /// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致) + #[test] + fn test_resolve_anchor_case_sensitive() { + let content = "Hello\nWorld"; + // 小写 "hello" 不命中第 1 行的大写 "Hello" → Err + let err = resolve_anchor_to_lines(content, "hello", "World").unwrap_err(); + assert!(format!("{}", err).contains("anchor.start 子串"), "got: {}", err); + } + + /// resolve_anchor_to_lines: start 子串找不到 Err + #[test] + fn test_resolve_anchor_start_not_found() { + let content = "a\nb\nc"; + let err = resolve_anchor_to_lines(content, "NOPE", "c").unwrap_err(); + assert!(format!("{}", err).contains("anchor.start 子串「NOPE」"), "got: {}", err); + } + + /// resolve_anchor_to_lines: end 子串在 start 之后找不到 Err + #[test] + fn test_resolve_anchor_end_not_found_after_start() { + // start 命中第 1 行,但 end 子串 "z" 在第 1 行之后不存在 + let content = "START\nb\nc"; + let err = resolve_anchor_to_lines(content, "START", "z").unwrap_err(); + assert!(format!("{}", err).contains("anchor.end 子串「z」"), "got: {}", err); + } + + /// resolve_anchor_to_lines: start/end 空串 Err + #[test] + fn test_resolve_anchor_empty_marker() { + let content = "a\nb"; + assert!(resolve_anchor_to_lines(content, "", "b").is_err()); + assert!(resolve_anchor_to_lines(content, "a", "").is_err()); + } + + /// 三模式组合案例:anchor 解析为行号 → apply_line_range 执行替换(端到端纯函数链) + #[test] + fn test_anchor_then_apply_line_range_e2e() { + let content = "use std::fs;\nuse std::io;\n\nfn main() {}\n"; + // 锚点定位 import 区块(第 1-2 行),替换为新 import + let (s, e) = resolve_anchor_to_lines(content, "use std::fs", "use std::io").unwrap(); + assert_eq!((s, e), (1, 2)); + let out = apply_line_range(content, s, e, "use std::path;").unwrap(); + assert_eq!(out, "use std::path;\n\nfn main() {}\n"); + } }