优化: 狗粮短板收口与patch_file增强
- patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明) - patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明 - tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压 - 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题 - prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议 - 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐
This commit is contained in:
@@ -71,7 +71,7 @@ pub fn register(
|
||||
registry,
|
||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
"read_file",
|
||||
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)",
|
||||
"读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)。遍历大文件用大 limit(300 左右)分段读,小页(15-40 行)遍历会成倍放大调用轮次",
|
||||
RiskLevel::Low,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
@@ -181,7 +181,7 @@ pub fn register(
|
||||
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})",
|
||||
"文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={}(建议 limit 300,大段遍历远快于小页;精读已定位片段再用小 limit;仅需函数签名用 read_symbol)",
|
||||
offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()),
|
||||
line_count - consumed,
|
||||
consumed,
|
||||
@@ -448,10 +448,10 @@ pub fn register(
|
||||
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("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 防并发行号漂移",
|
||||
"description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一。配 expected_hash 防并发行号漂移",
|
||||
"properties": {
|
||||
"start": { "type": "integer", "description": "起始行号(1-based,含)" },
|
||||
"end": { "type": "integer", "description": "结束行号(1-based,含)" }
|
||||
@@ -460,10 +460,10 @@ pub fn register(
|
||||
}));
|
||||
props.insert("anchor".into(), serde_json::json!({
|
||||
"type": "object",
|
||||
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文",
|
||||
"description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一。不需完整原文",
|
||||
"properties": {
|
||||
"start": { "type": "string", "description": "首行子串标记(大小写敏感)" },
|
||||
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" }
|
||||
"end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后,须唯一命中;多处命中会报错并列出命中位置)" }
|
||||
},
|
||||
"required": ["start", "end"]
|
||||
}));
|
||||
@@ -479,7 +479,7 @@ pub fn register(
|
||||
registry,
|
||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
"patch_file",
|
||||
"局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||
"局部更新文件内容(三模式定位)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义);精确未中时自动按空白归一化(行首缩进归一+多空白折叠)重试,唯一命中即按整行区间替换并在 warning 注明「空白归一化命中」。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文;end 锚点须唯一命中,多处命中报错并列出前两处位置(届时缩小 end 子串或改用 old_text)。三个定位参数同时传入时按优先级 anchor > old_text > replace_lines 仲裁取一执行并在 warning 注明,全缺才报错。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
path_args: &["path"],
|
||||
@@ -500,8 +500,11 @@ pub fn register(
|
||||
let new_text = args["new_text"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
|
||||
|
||||
// 三模式互斥校验(old_text / replace_lines / anchor)
|
||||
// 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式)
|
||||
// 三模式定位(old_text / replace_lines / anchor)
|
||||
// 旧实现:三选一互斥,>1 直接拒绝。实测 LLM 反复同时传 old_text+anchor(×3)
|
||||
// 被拒后重试仍传双参,空耗往返。改:多模式按优先级仲裁取一执行
|
||||
// (anchor > old_text > replace_lines,见 arbitrate_patch_mode),
|
||||
// result.warning 注明仲裁结果;全缺时才报参数缺失。
|
||||
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);
|
||||
@@ -509,12 +512,8 @@ pub fn register(
|
||||
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 (use_anchor, use_old_text, use_replace_lines, mode_arbitration) =
|
||||
arbitrate_patch_mode(has_anchor, has_old_text, has_replace_lines);
|
||||
|
||||
// 文件存在性 / 大小限制(三模式通用,先于内容读取)
|
||||
let target = std::path::Path::new(path);
|
||||
@@ -572,7 +571,7 @@ pub fn register(
|
||||
let match_count: usize;
|
||||
let warning: Option<String>;
|
||||
|
||||
if has_old_text {
|
||||
if use_old_text {
|
||||
let old_text = args["old_text"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
|
||||
if old_text.is_empty() {
|
||||
@@ -583,11 +582,26 @@ pub fn register(
|
||||
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
|
||||
}));
|
||||
}
|
||||
// L2: old_text 精确匹配(CAS 语义)
|
||||
// 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM,
|
||||
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
|
||||
// 退回原提示,不误导。
|
||||
if !content.contains(old_text) {
|
||||
// L2: old_text 精确匹配(CAS 语义)→ 空白归一化回退 → 相近片段诊断。
|
||||
// 实测 ×15 失败是 LLM 缩进/空白对不齐:精确未中时按「行首缩进归一+多空白
|
||||
// 折叠」归一化重匹配,唯一命中自动采用(治根),多命中/零命中维持原报错,
|
||||
// 附相近片段供 LLM 对照真实缩进修正(AC-5)。
|
||||
if content.contains(old_text) {
|
||||
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 let Some((sl, el)) = find_whitespace_normalized_match(&content, old_text) {
|
||||
// 归一化唯一命中:按整行区间替换(old_text 归一化行与目标行全等,
|
||||
// 首尾行无需保留的行内其他内容)。warning 注明命中方式,LLM 可知情复核。
|
||||
new_content = apply_line_range(&content, sl, el, new_text)?;
|
||||
match_count = 1;
|
||||
warning = Some(format!(
|
||||
"空白归一化命中(精确匹配未中,第 {sl}-{el} 行已按整行区间替换);建议重读文件核对实际缩进"
|
||||
));
|
||||
} else {
|
||||
let similar = similar_line_fragments(&content, old_text, 3);
|
||||
if similar.is_empty() {
|
||||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||||
@@ -597,13 +611,7 @@ pub fn register(
|
||||
similar.join("\n")
|
||||
);
|
||||
}
|
||||
let mc = content.matches(old_text).count();
|
||||
match_count = mc;
|
||||
warning = if mc > 1 {
|
||||
Some(format!("匹配到 {} 处,仅替换第 1 处", mc))
|
||||
} else { None };
|
||||
new_content = content.replacen(old_text, new_text, 1);
|
||||
} else if has_replace_lines {
|
||||
} else if use_replace_lines {
|
||||
let rl = args.get("replace_lines")
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
|
||||
let start = rl["start"].as_u64()
|
||||
@@ -614,7 +622,7 @@ pub fn register(
|
||||
match_count = 1;
|
||||
warning = None;
|
||||
} else {
|
||||
// has_anchor
|
||||
// use_anchor
|
||||
let an = args.get("anchor")
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
|
||||
let a_start = an["start"].as_str()
|
||||
@@ -652,15 +660,31 @@ pub fn register(
|
||||
// 生成 unified diff 供前端审批卡/审计留痕展示
|
||||
// content(旧)与 new_content(新)作用域内可用,无需重读文件
|
||||
let diff = generate_diff(&content, &new_content);
|
||||
let mode_name = if use_anchor {
|
||||
"anchor"
|
||||
} else if use_old_text {
|
||||
"old_text"
|
||||
} else {
|
||||
"replace_lines"
|
||||
};
|
||||
let mut result = serde_json::json!({
|
||||
"path": path,
|
||||
"changed": true,
|
||||
"mode": mode_name,
|
||||
"size_diff": size_diff,
|
||||
"matches_found": match_count,
|
||||
"diff": diff,
|
||||
});
|
||||
// 汇总提示:多模式仲裁说明 + 分支 warning(空白归一化命中/多匹配仅换第 1 处)
|
||||
let mut notes: Vec<String> = Vec::new();
|
||||
if let Some(a) = mode_arbitration {
|
||||
notes.push(a);
|
||||
}
|
||||
if let Some(w) = warning {
|
||||
result["warning"] = serde_json::Value::String(w);
|
||||
notes.push(w);
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
result["warning"] = serde_json::Value::String(notes.join(";"));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1032,7 +1056,13 @@ pub fn register(
|
||||
// 旧实现直接 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))?;
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
file_not_found_error(root)
|
||||
} else {
|
||||
anyhow::anyhow!("无法访问路径 {}: {}", root, e)
|
||||
}
|
||||
})?;
|
||||
if root_meta.is_file() {
|
||||
let file_name = std::path::Path::new(root)
|
||||
.file_name()
|
||||
@@ -1379,7 +1409,7 @@ async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> {
|
||||
let mut file = File::open(path).await
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
anyhow::anyhow!("文件不存在: {}", path)
|
||||
file_not_found_error(path)
|
||||
} else {
|
||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||
}
|
||||
@@ -1473,6 +1503,84 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String
|
||||
scored.into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// patch_file 多模式仲裁 + 空白归一化回退 — 纯函数(便于单测,handler 内复用)
|
||||
// ============================================================
|
||||
|
||||
/// patch_file 多模式仲裁:同时传入多个定位参数时按优先级取一执行。
|
||||
///
|
||||
/// 优先级 anchor > old_text > replace_lines(anchor 语义最精确,行号区间次之)。
|
||||
/// 返回 (use_anchor, use_old_text, use_replace_lines, 仲裁说明):单模式时原样放行、
|
||||
/// 说明为 None;多模式时只开选中开关,说明含「检测到多模式,已按 X 执行」。
|
||||
/// 全 false(缺定位方式)由调用方先行报错,本函数不处理。
|
||||
fn arbitrate_patch_mode(
|
||||
has_anchor: bool,
|
||||
has_old_text: bool,
|
||||
has_replace_lines: bool,
|
||||
) -> (bool, bool, bool, Option<String>) {
|
||||
let count = [has_anchor, has_old_text, has_replace_lines]
|
||||
.iter()
|
||||
.filter(|&&b| b)
|
||||
.count();
|
||||
if count <= 1 {
|
||||
return (has_anchor, has_old_text, has_replace_lines, None);
|
||||
}
|
||||
let chosen = if has_anchor {
|
||||
"anchor"
|
||||
} else if has_old_text {
|
||||
"old_text"
|
||||
} else {
|
||||
"replace_lines"
|
||||
};
|
||||
(
|
||||
chosen == "anchor",
|
||||
chosen == "old_text",
|
||||
chosen == "replace_lines",
|
||||
Some(format!(
|
||||
"检测到多模式({count} 个),已按 {chosen} 执行(优先级 anchor > old_text > replace_lines)"
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
/// 单行空白归一化:所有空白序列(行首缩进/制表符/连续空格)折叠为单个空格。
|
||||
/// " let x = 1;" 归一化为 "let x = 1;"。
|
||||
fn normalize_ws_line(line: &str) -> String {
|
||||
line.split_whitespace().collect::<Vec<&str>>().join(" ")
|
||||
}
|
||||
|
||||
/// 空白归一化行序列匹配:old_text 各行归一化后在 content 归一化行序列中找连续命中。
|
||||
///
|
||||
/// 用途:patch_file old_text 精确匹配未中时的回退(实测 ×15 缩进/空白对不齐)。
|
||||
/// 仅当归一化后**唯一**命中才返回 Some((start_line, end_line))(1-based 含首尾);
|
||||
/// 多命中(歧义)/零命中返回 None,调用方维持原报错(含相近片段诊断)。
|
||||
/// 匹配为整行粒度:old_text 各归一化行须与目标行全等,故命中区间按整行替换不丢内容。
|
||||
fn find_whitespace_normalized_match(content: &str, old_text: &str) -> Option<(usize, usize)> {
|
||||
let target: Vec<String> = old_text.lines().map(normalize_ws_line).collect();
|
||||
// old_text 归一化后全空行 → 无意义锚点,不回退(交由精确路径报错)
|
||||
if target.iter().all(|l| l.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let hay: Vec<String> = content.lines().map(normalize_ws_line).collect();
|
||||
let m = target.len();
|
||||
if hay.len() < m {
|
||||
return None;
|
||||
}
|
||||
let mut hits: Vec<usize> = Vec::new();
|
||||
for i in 0..=hay.len() - m {
|
||||
if hay[i..i + m] == target[..] {
|
||||
hits.push(i);
|
||||
if hits.len() > 1 {
|
||||
break; // 多命中即歧义,提前终止
|
||||
}
|
||||
}
|
||||
}
|
||||
if hits.len() == 1 {
|
||||
Some((hits[0] + 1, hits[0] + m))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// read_file/read_symbol NotFound 提示辅助 — 相近文件名候选
|
||||
// ============================================================
|
||||
@@ -1742,6 +1850,96 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// patch_file 多模式仲裁 + 空白归一化回退 — 三态锁死(命中/多命中/零命中)
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn arbitrate_single_mode_passthrough_no_note() {
|
||||
// 单模式/零模式:开关原样放行,无仲裁说明
|
||||
assert_eq!(arbitrate_patch_mode(false, true, false), (false, true, false, None));
|
||||
assert_eq!(arbitrate_patch_mode(false, false, true), (false, false, true, None));
|
||||
assert_eq!(arbitrate_patch_mode(true, false, false), (true, false, false, None));
|
||||
assert_eq!(arbitrate_patch_mode(false, false, false), (false, false, false, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrate_multi_mode_prefers_anchor_then_old_text() {
|
||||
// 优先级 anchor > old_text > replace_lines,仲裁说明含选中模式名
|
||||
let (a, o, r, note) = arbitrate_patch_mode(true, true, true);
|
||||
assert!(a && !o && !r, "三模式全传应选 anchor");
|
||||
assert!(note.unwrap().contains("anchor"));
|
||||
let (a, o, r, note) = arbitrate_patch_mode(false, true, true);
|
||||
assert!(!a && o && !r, "old_text+replace_lines 应选 old_text");
|
||||
assert!(note.unwrap().contains("old_text"));
|
||||
let (a, o, r, _) = arbitrate_patch_mode(true, false, true);
|
||||
assert!(a && !o && !r, "anchor+replace_lines 应选 anchor");
|
||||
// 仲裁说明措辞契约(LLM 依赖该文案知情):「检测到多模式」「已按 X 执行」
|
||||
let (_, _, _, note) = arbitrate_patch_mode(true, true, false);
|
||||
let text = note.unwrap();
|
||||
assert!(text.contains("检测到多模式"), "实际: {text}");
|
||||
assert!(text.contains("已按 anchor 执行"), "实际: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_ws_line_collapses_all_whitespace() {
|
||||
assert_eq!(normalize_ws_line(" let x\t= 1; "), "let x = 1;");
|
||||
assert_eq!(normalize_ws_line(""), "");
|
||||
assert_eq!(normalize_ws_line("\t\t"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_unique_indent_drift_hits() {
|
||||
// 实测主失败形态:缩进漂移(2/6 空格 vs LLM 传 4 空格)+ 行内多空白 → 唯一命中
|
||||
let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
|
||||
let old = " let y = 2;";
|
||||
assert_eq!(find_whitespace_normalized_match(content, old), Some((3, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_multiline_block_hits() {
|
||||
// 多行块(含 tab 缩进):归一化后行序列连续命中第 2-3 行
|
||||
let content = "a\n one two;\n\tthree;\nb\n";
|
||||
let old = "one two;\n three;";
|
||||
assert_eq!(find_whitespace_normalized_match(content, old), Some((2, 3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_exact_compatible_scenario() {
|
||||
// 精确匹配本可命中的场景,归一化同样定位到同区间(handler 精确优先,此为一致性)
|
||||
let content = "let x = 1;\n";
|
||||
assert_eq!(find_whitespace_normalized_match(content, "let x = 1;"), Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_multi_hit_returns_none() {
|
||||
// 多命中 = 歧义 → None(handler 维持报错路径,不瞎选)
|
||||
let content = "x = 1;\ny = 2;\nx = 1;\n";
|
||||
assert_eq!(find_whitespace_normalized_match(content, "x = 1;"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_no_hit_returns_none() {
|
||||
// 零命中 → None(handler 维持相近片段诊断报错)
|
||||
let content = "fn a() {}\nfn b() {}\n";
|
||||
assert_eq!(find_whitespace_normalized_match(content, "fn totally_other() {}"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_blank_old_text_returns_none() {
|
||||
// 全空行 old_text → None(交由精确路径报错)
|
||||
assert_eq!(find_whitespace_normalized_match("a\n\nb\n", "\n\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_match_range_feeds_line_replace_e2e() {
|
||||
// 归一化命中行区间 → apply_line_range 整行替换(handler 同款调用链)
|
||||
let content = "keep\n let x = 1;\n let y = 2;\nkeep\n";
|
||||
let (sl, el) = find_whitespace_normalized_match(content, " let x = 1;").unwrap();
|
||||
let out = apply_line_range(content, sl, el, "REPLACED").unwrap();
|
||||
assert_eq!(out, "keep\nREPLACED\n let y = 2;\nkeep\n");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user