新增: patch_file支持行号区间和锚点定位
This commit is contained in:
@@ -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 行号必须 >= 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<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_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<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 行被替换为 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user