# LineEdit V2 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 重构 LineEdit 工具，引入 action 枚举、expected_lines 验证、全有或全无原子性、上下文 diff 反馈，彻底解决连续编辑场景的 12 个已知缺陷。

**Architecture:** 在现有 `line_edit.rs` 上原地重构。两阶段执行引擎（validate → apply），移除 `insert`/`start_word`/`end_word`，新增 `action`/`expected_lines` 字段。反馈格式改为含上下文行的 diff 风格。

**Tech Stack:** Rust, serde_json, tokio (async tests), tempfile (test fixture), uuid (atomic_write)

**Design Spec:** `docs/superpowers/specs/2026-06-06-lineedit-v2-design.md`

---

## File Structure

| File | Action | Responsibility |
|------|--------|---------------|
| `peri-middlewares/src/tools/filesystem/line_edit.rs` | Rewrite | 核心实现：EditEntry、两阶段引擎、反馈、描述 |
| `peri-middlewares/src/tools/filesystem/line_edit_test.rs` | Rewrite | 全部测试覆盖 |
| `CLAUDE.md` | Modify | 更新 lineEdit beta 描述，移除 start_word/end_word 提及 |
| `prompts/lineedit_stress_test.txt` | Modify | 移除 start_word/end_word 相关注释 |

Registration files (`middleware/filesystem.rs`, `tool_search/core_tools.rs`, `mod.rs`) — 无需改动，工具名 `LineEdit` 不变。

---

### Task 1: EditEntry 结构体 + Action 枚举 + parameters()

**Files:**
- Rewrite: `peri-middlewares/src/tools/filesystem/line_edit.rs`

- [ ] **Step 1: 重写 EditEntry 结构体和 LINE_EDIT_DESCRIPTION**

将 `line_edit.rs` 全文替换为以下骨架（保留 `atomic_write` 不变）：

```rust
use peri_agent::tools::BaseTool;
use serde::Deserialize;
use serde_json::Value;

use super::resolve_path;

const LINE_EDIT_DESCRIPTION: &str = r#"Performs precise line-based edits in files.

Line numbers are 1-based (from Read output). Multiple edits are applied bottom-to-top.
All edits in one call are atomic — if any edit fails, no changes are written.

Actions (set "action" field):
- "replace" (default): Replace lines start_line..end_line with new_string.
- "insert": Insert new_string BEFORE start_line. No existing lines are removed.
- "delete": Remove lines start_line..end_line. new_string is ignored, can be "".

Verification with expected_lines (recommended):
- Set to the content you expect at start_line..end_line from your last Read.
- If actual content differs, a warning is returned but the edit still proceeds.
- This catches stale line numbers after concurrent changes.

Rules:
- new_string replaces the ENTIRE target range — do not duplicate adjacent lines.
- For whole-line edits, use start_line/end_line only.
- Multiple edits to the same file must not overlap.

Common patterns:
- Replace lines: {start_line: 42, end_line: 44, expected_lines: "...", new_string: "..."}
- Insert before line: {start_line: 42, action: "insert", new_string: "new line"}
- Delete lines: {start_line: 42, end_line: 44, action: "delete", new_string: ""}
- Single line: {start_line: 42, new_string: "replacement content"}"#;

/// 编辑动作
#[derive(Debug, Clone, PartialEq)]
enum EditAction {
    Replace,
    Insert,
    Delete,
}

/// 单个编辑操作
#[derive(Debug, Deserialize)]
pub struct EditEntry {
    pub file_path: String,
    pub start_line: usize,
    #[serde(default)]
    pub end_line: Option<usize>,
    #[serde(default)]
    pub action: Option<String>,
    pub new_string: String,
    #[serde(default)]
    pub expected_lines: Option<String>,
}

impl EditEntry {
    fn resolve_action(&self) -> EditAction {
        if let Some(ref action) = self.action {
            match action.as_str() {
                "insert" => EditAction::Insert,
                "delete" => EditAction::Delete,
                _ => EditAction::Replace,
            }
        } else if self.new_string.is_empty() {
            EditAction::Delete
        } else {
            EditAction::Replace
        }
    }

    fn effective_end_line(&self) -> usize {
        self.end_line.unwrap_or(self.start_line)
    }
}

/// 验证结果
enum ValidateOutcome {
    Ok,
    Warn { expected: String, actual: String },
}

struct ValidateError {
    edit_index: usize,
    message: String,
}

/// 编辑应用结果
struct EditResult {
    file_path: String,
    start_line: usize,
    end_line: usize,
    action: EditAction,
    old_line_count: usize,
    new_line_count: usize,
    validation: ValidateOutcome,
    context_before: Vec<String>,
    context_after: Vec<String>,
    old_lines: Vec<String>,
    new_lines: Vec<String>,
}

/// LineEdit 工具 — 基于行号的精确编辑
pub struct LineEditTool {
    pub cwd: String,
}

impl LineEditTool {
    pub fn new(cwd: impl Into<String>) -> Self {
        Self { cwd: cwd.into() }
    }
}

#[async_trait::async_trait]
impl BaseTool for LineEditTool {
    fn name(&self) -> &str {
        "LineEdit"
    }

    fn description(&self) -> &str {
        LINE_EDIT_DESCRIPTION
    }

    fn parameters(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "edits": {
                    "type": "array",
                    "items": {
                        "type": "object",
                        "properties": {
                            "file_path": {
                                "type": "string",
                                "description": "Absolute path to the file to modify"
                            },
                            "start_line": {
                                "type": "integer",
                                "description": "1-based line number to start editing at (from Read output)"
                            },
                            "end_line": {
                                "type": "integer",
                                "description": "Line number to end editing at. Defaults to start_line. Ignored when action=insert."
                            },
                            "action": {
                                "type": "string",
                                "enum": ["replace", "insert", "delete"],
                                "description": "Edit action. 'replace' (default): replace lines start_line..end_line with new_string. 'insert': insert new_string before start_line, no lines removed. 'delete': remove lines start_line..end_line, new_string ignored."
                            },
                            "new_string": {
                                "type": "string",
                                "description": "Replacement text. For replace: the new content. For insert: content to insert. For delete: ignored, can be empty string."
                            },
                            "expected_lines": {
                                "type": "string",
                                "description": "Optional but recommended: content you expect at start_line..end_line from your last Read. If actual content differs, a warning is returned but edit still proceeds."
                            }
                        },
                        "required": ["file_path", "start_line", "new_string"]
                    }
                }
            },
            "required": ["edits"]
        })
    }

    async fn invoke(
        &self,
        input: Value,
    ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
        // 占位：Task 2 实现
        todo!("Task 2")
    }
}

/// 原子写入：临时文件 + rename
fn atomic_write(path: &std::path::Path, content: &str) -> Result<(), std::io::Error> {
    let tmp_ext = format!("tmp.{}", uuid::Uuid::now_v7());
    let tmp_path = path.with_extension(tmp_ext);
    std::fs::write(&tmp_path, content)?;
    match std::fs::rename(&tmp_path, path) {
        Ok(_) => Ok(()),
        Err(e) => {
            let _ = std::fs::remove_file(&tmp_path);
            Err(e)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    include!("line_edit_test.rs");
}
```

- [ ] **Step 2: 编译验证骨架能通过**

Run: `cargo build -p peri-middlewares 2>&1 | head -20`
Expected: 编译失败仅因为 `todo!()` 和测试文件引用旧参数（Task 3 会修复测试）。确认没有类型/结构体层面的编译错误。

- [ ] **Step 3: Commit**

```bash
git add peri-middlewares/src/tools/filesystem/line_edit.rs
git commit -m "refactor(lineedit): rewrite EditEntry with action/expected_lines, remove insert/start_word/end_word

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>"
```

---

### Task 2: 两阶段执行引擎（validate + apply）

**Files:**
- Modify: `peri-middlewares/src/tools/filesystem/line_edit.rs`

- [ ] **Step 1: 实现 invoke 方法的两阶段逻辑**

替换 `invoke` 中的 `todo!("Task 2")` 为完整实现：

```rust
async fn invoke(
    &self,
    input: Value,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
    let edits: Vec<EditEntry> = serde_json::from_value(input["edits"].clone())
        .map_err(|e| format!("edits 参数解析失败: {}", e))?;

    if edits.is_empty() {
        return Err("edits 不能为空".into());
    }

    // 按文件分组
    let mut groups: std::collections::BTreeMap<String, Vec<usize>> =
        std::collections::BTreeMap::new();
    for (i, edit) in edits.iter().enumerate() {
        let resolved = resolve_path(&self.cwd, &edit.file_path);
        let key = resolved.to_string_lossy().to_string();
        groups.entry(key).or_default().push(i);
    }

    // 读取所有文件内容
    let mut file_contents: std::collections::HashMap<String, (Vec<String>, &str, bool)> =
        std::collections::HashMap::new();
    for (file_key, _) in &groups {
        let content = match std::fs::read_to_string(file_key) {
            Ok(c) => c,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                return Err(format!("文件不存在: {}", file_key).into());
            }
            Err(e) => return Err(e.into()),
        };
        let trailing_newline = content.ends_with('\n');
        let line_ending = if content.contains("\r\n") { "\r\n" } else { "\n" };
        let lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
        file_contents.insert(file_key.clone(), (lines, line_ending, trailing_newline));
    }

    // 阶段 1: 验证所有编辑
    let mut validation_errors: Vec<ValidateError> = Vec::new();
    let mut validation_warnings: Vec<Option<ValidateOutcome>> = vec![None; edits.len()];

    for (file_key, edit_indices) in &groups {
        let (lines, _, _) = file_contents.get(file_key).unwrap();

        // 检查重叠
        let mut sorted_by_line: Vec<usize> = edit_indices.clone();
        sorted_by_line.sort_by_key(|&i| std::cmp::Reverse(edits[i].start_line));
        for window in sorted_by_line.windows(2) {
            let a_idx = window[0]; // higher line
            let b_idx = window[1]; // lower line
            let a = &edits[a_idx];
            let b = &edits[b_idx];
            let a_action = a.resolve_action();
            let b_action = b.resolve_action();
            if a_action != EditAction::Insert && b_action != EditAction::Insert {
                let a_start = a.start_line;
                let a_end = a.effective_end_line();
                let b_start = b.start_line;
                let b_end = b.effective_end_line();
                // a has higher start_line, so check if a_start <= b_end
                if a_start <= b_end {
                    validation_errors.push(ValidateError {
                        edit_index: a_idx,
                        message: format!(
                            "第 {}-{} 行与第 {}-{} 行重叠，请调整范围",
                            a_start, a_end, b_start, b_end
                        ),
                    });
                }
            }
        }

        // 验证每个编辑
        for &i in edit_indices {
            let edit = &edits[i];
            let action = edit.resolve_action();
            let end_line = edit.effective_end_line();

            // 行号基本检查
            if edit.start_line == 0 {
                validation_errors.push(ValidateError {
                    edit_index: i,
                    message: "start_line 必须 >= 1".to_string(),
                });
                continue;
            }

            match action {
                EditAction::Insert => {
                    if edit.start_line > lines.len() + 1 {
                        validation_errors.push(ValidateError {
                            edit_index: i,
                            message: format!(
                                "insert 位置 {} 超出范围 (文件共 {} 行，最大可插入到 {})",
                                edit.start_line,
                                lines.len(),
                                lines.len() + 1
                            ),
                        });
                    }
                }
                EditAction::Replace | EditAction::Delete => {
                    if end_line < edit.start_line {
                        validation_errors.push(ValidateError {
                            edit_index: i,
                            message: format!(
                                "end_line ({}) 不能小于 start_line ({})",
                                end_line, edit.start_line
                            ),
                        });
                        continue;
                    }
                    if edit.start_line > lines.len() {
                        validation_errors.push(ValidateError {
                            edit_index: i,
                            message: format!(
                                "start_line {} 超出文件行数 (共 {} 行)",
                                edit.start_line,
                                lines.len()
                            ),
                        });
                        continue;
                    }
                    if end_line > lines.len() {
                        validation_errors.push(ValidateError {
                            edit_index: i,
                            message: format!(
                                "end_line {} 超出文件行数 (共 {} 行)",
                                end_line,
                                lines.len()
                            ),
                        });
                        continue;
                    }

                    // expected_lines 验证（警告但继续）
                    if let Some(ref expected) = edit.expected_lines {
                        let start_idx = edit.start_line - 1;
                        let end_idx = end_line - 1;
                        match verify_expected_lines(lines, start_idx, end_idx, expected) {
                            ExpectedLinesResult::Match => {
                                validation_warnings[i] = Some(ValidateOutcome::Ok);
                            }
                            ExpectedLinesResult::Mismatch { expected: exp, actual } => {
                                validation_warnings[i] = Some(ValidateOutcome::Warn {
                                    expected: exp,
                                    actual,
                                });
                            }
                        }
                    }
                }
            }
        }
    }

    // 如果有验证错误 → 全部拒绝
    if !validation_errors.is_empty() {
        let mut msgs: Vec<String> = Vec::new();
        for err in &validation_errors {
            msgs.push(format!("✗ Edit {}: {}", err.edit_index + 1, err.message));
        }
        msgs.push("未执行任何编辑。修正后重试。".to_string());
        return Ok(msgs.join("\n"));
    }

    // 阶段 2: 应用所有编辑
    let mut results: Vec<EditResult> = Vec::new();

    for (file_key, edit_indices) in &groups {
        let (lines, line_ending, trailing_newline) =
            file_contents.get(file_key).unwrap().clone();
        let mut lines = lines;

        // 按行号降序排列
        let mut sorted: Vec<usize> = edit_indices.clone();
        sorted.sort_by_key(|&i| std::cmp::Reverse(edits[i].start_line));

        for &i in sorted {
            let edit = &edits[i];
            let action = edit.resolve_action();
            let start_line = edit.start_line;
            let end_line = edit.effective_end_line();
            let start_idx = start_line - 1;
            let end_idx = end_line - 1;

            // 保存上下文用于反馈
            let ctx_before: Vec<String> = if start_idx > 0 {
                lines[start_idx.saturating_sub(2)..start_idx].to_vec()
            } else {
                vec![]
            };

            let old_lines: Vec<String> = match action {
                EditAction::Insert => vec![],
                _ => lines[start_idx..=end_idx].to_vec(),
            };
            let old_line_count = old_lines.len();

            // 应用编辑
            let new_lines_vec: Vec<String>;
            match action {
                EditAction::Replace => {
                    new_lines_vec = edit.new_string.lines().map(|s| s.to_string()).collect();
                    lines.splice(start_idx..=end_idx, new_lines_vec.clone());
                }
                EditAction::Insert => {
                    new_lines_vec = if edit.new_string.is_empty() {
                        vec![]
                    } else {
                        edit.new_string.lines().map(|s| s.to_string()).collect()
                    };
                    for (j, line) in new_lines_vec.iter().enumerate() {
                        lines.insert(start_idx + j, line.clone());
                    }
                }
                EditAction::Delete => {
                    new_lines_vec = vec![];
                    lines.splice(start_idx..=end_idx, std::iter::empty());
                }
            }

            // 保存编辑后上下文
            let new_start_idx = match action {
                EditAction::Insert => start_idx,
                _ => start_idx,
            };
            let new_end_idx = new_start_idx + new_lines_vec.len().saturating_sub(1);
            let ctx_after_end = (new_end_idx + 3).min(lines.len());
            let ctx_after: Vec<String> = if ctx_after_end > new_end_idx + 1 {
                lines[new_end_idx + 1..ctx_after_end].to_vec()
            } else {
                vec![]
            };

            results.push(EditResult {
                file_path: edit.file_path.clone(),
                start_line,
                end_line,
                action,
                old_line_count,
                new_line_count: new_lines_vec.len(),
                validation: validation_warnings[i].clone().unwrap_or(ValidateOutcome::Ok),
                context_before: ctx_before,
                context_after: ctx_after,
                old_lines,
                new_lines: new_lines_vec,
            });
        }

        // 写入文件
        let new_content = if lines.is_empty() {
            String::new()
        } else {
            let mut s = lines.join(line_ending);
            if trailing_newline {
                s.push_str(line_ending);
            }
            s
        };
        atomic_write(std::path::Path::new(file_key), &new_content)?;
    }

    // 构建反馈
    Ok(format_results(&results))
}
```

- [ ] **Step 2: 实现 verify_expected_lines 和 format_results 辅助函数**

在 `impl LineEditTool` 块之前添加：

```rust
enum ExpectedLinesResult {
    Match,
    Mismatch { expected: String, actual: String },
}

fn verify_expected_lines(
    lines: &[String],
    start_idx: usize,
    end_idx: usize,
    expected: &str,
) -> ExpectedLinesResult {
    let actual: String = lines[start_idx..=end_idx]
        .iter()
        .map(|l| l.trim_end().to_string())
        .collect::<Vec<_>>()
        .join("\n");
    let expected_norm: String = expected
        .lines()
        .map(|l| l.trim_end())
        .collect::<Vec<_>>()
        .join("\n");

    if actual == expected_norm {
        ExpectedLinesResult::Match
    } else {
        ExpectedLinesResult::Mismatch {
            expected: expected_norm,
            actual,
        }
    }
}

fn truncate_str(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        s.to_string()
    } else {
        s.chars().take(max_chars).collect::<String>() + "..."
    }
}

fn format_results(results: &[EditResult]) -> String {
    let mut output = Vec::new();

    for r in results {
        let action_str = match r.action {
            EditAction::Replace => "replace",
            EditAction::Insert => "insert",
            EditAction::Delete => "delete",
        };

        let status = match &r.validation {
            ValidateOutcome::Ok => "✓",
            ValidateOutcome::Warn { .. } => "⚠",
        };

        let line_range = if r.start_line == r.end_line {
            format!("{}", r.start_line)
        } else {
            format!("{}-{}", r.start_line, r.end_line)
        };

        if matches!(&r.validation, ValidateOutcome::Warn { .. }) {
            if let ValidateOutcome::Warn { expected, actual } = &r.validation {
                output.push(format!(
                    "{} {}:{} {} ({}→{} lines) expected_lines 不匹配",
                    status, r.file_path, line_range, action_str,
                    r.old_line_count, r.new_line_count
                ));
                output.push(format!("  预期: {}", truncate_str(expected, 80)));
                output.push(format!("  实际: {}", truncate_str(actual, 80)));
                output.push("  编辑已执行，建议 Re-read 确认结果".to_string());
            }
        } else {
            output.push(format!(
                "{} {}:{} {} ({}→{} lines)",
                status, r.file_path, line_range, action_str,
                r.old_line_count, r.new_line_count
            ));
        }

        // 上下文 diff
        let total_lines = r.context_before.len() + r.old_lines.len().max(r.new_lines.len()) + r.context_after.len();
        if total_lines <= 30 {
            // 上下文前
            let before_start = r.start_line.saturating_sub(r.context_before.len());
            for (i, line) in r.context_before.iter().enumerate() {
                output.push(format!("{:>4} | {}", before_start + i, line));
            }
            // 旧行（删除标记）
            for (i, line) in r.old_lines.iter().enumerate() {
                output.push(format!("{:>4} |-{}", r.start_line + i, line));
            }
            // 新行（插入标记）
            for line in r.new_lines.iter() {
                output.push(format!("{:>4} |+{}", r.start_line, line));
            }
            // 上下文后
            let after_start = r.start_line + r.new_lines.len();
            for (i, line) in r.context_after.iter().enumerate() {
                output.push(format!("{:>4} | {}", after_start + i, line));
            }
        } else {
            // 截断模式：只显示首尾
            output.push(format!("  ... ({} 行变更，已省略) ...", r.old_lines.len().max(r.new_lines.len())));
        }
    }

    output.join("\n")
}
```

- [ ] **Step 3: 编译验证**

Run: `cargo build -p peri-middlewares 2>&1 | head -30`
Expected: 编译错误仅来自测试文件（Task 3 修复）。核心实现无类型错误。

- [ ] **Step 4: Commit**

```bash
git add peri-middlewares/src/tools/filesystem/line_edit.rs
git commit -m "feat(lineedit): implement two-phase validate+apply engine with expected_lines

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>"
```

---

### Task 3: 测试文件完全重写

**Files:**
- Rewrite: `peri-middlewares/src/tools/filesystem/line_edit_test.rs`

- [ ] **Step 1: 重写测试文件为 V2 覆盖**

完全替换 `line_edit_test.rs` 内容：

```rust
#[allow(unused_imports)]
fn make_tool(dir: &tempfile::TempDir) -> LineEditTool {
    LineEditTool::new(dir.path().to_str().unwrap())
}

fn make_edit(file: &str, start_line: usize, new_string: &str) -> serde_json::Value {
    serde_json::json!({
        "file_path": file,
        "start_line": start_line,
        "new_string": new_string
    })
}

// ─── action 枚举测试 ─────────────────────────────────────────────────────

#[tokio::test]
async fn test_action_replace_显式() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 2,
            "action": "replace",
            "new_string": "BBB"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nBBB\nccc\n"
    );
}

#[tokio::test]
async fn test_action_insert_不删除旧行() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [{
                "file_path": "f.txt",
                "start_line": 2,
                "action": "insert",
                "new_string": "xxx\nyyy"
            }]
        }))
        .await
        .unwrap();
    assert!(result.contains("insert"), "unexpected: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nxxx\nyyy\nbbb\n"
    );
}

#[tokio::test]
async fn test_action_delete_忽略new_string() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\nddd\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 2,
            "end_line": 3,
            "action": "delete",
            "new_string": "ignored content"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nddd\n"
    );
}

#[tokio::test]
async fn test_action_默认replace() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [make_edit("f.txt", 2, "BBB")]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nBBB\n"
    );
}

#[tokio::test]
async fn test_action_默认delete_空new_string() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [make_edit("f.txt", 2, "")]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nccc\n"
    );
}

// ─── expected_lines 验证测试 ──────────────────────────────────────────────

#[tokio::test]
async fn test_expected_lines_匹配() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [{
                "file_path": "f.txt",
                "start_line": 2,
                "end_line": 2,
                "expected_lines": "bbb",
                "new_string": "BBB"
            }]
        }))
        .await
        .unwrap();
    assert!(result.contains("✓"), "应标记成功: {result}");
    assert!(!result.contains("不匹配"), "不应有不匹配警告: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nBBB\nccc\n"
    );
}

#[tokio::test]
async fn test_expected_lines_不匹配_警告但执行() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [{
                "file_path": "f.txt",
                "start_line": 2,
                "end_line": 2,
                "expected_lines": "wrong content",
                "new_string": "BBB"
            }]
        }))
        .await
        .unwrap();
    assert!(result.contains("⚠"), "应标记警告: {result}");
    assert!(result.contains("不匹配"), "应包含不匹配信息: {result}");
    assert!(result.contains("已执行"), "应继续执行: {result}");
    // 编辑仍然执行了
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nBBB\nccc\n"
    );
}

#[tokio::test]
async fn test_expected_lines_尾部空白归一化() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb   \nccc\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [{
                "file_path": "f.txt",
                "start_line": 2,
                "expected_lines": "bbb",
                "new_string": "BBB"
            }]
        }))
        .await
        .unwrap();
    assert!(result.contains("✓"), "尾部空白应被归一化: {result}");
}

#[tokio::test]
async fn test_expected_lines_多行验证() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\nddd\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [{
                "file_path": "f.txt",
                "start_line": 2,
                "end_line": 3,
                "expected_lines": "bbb\nccc",
                "new_string": "XXX"
            }]
        }))
        .await
        .unwrap();
    assert!(result.contains("✓"), "多行验证应通过: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nXXX\nddd\n"
    );
}

// ─── 原子性测试 ──────────────────────────────────────────────────────────

#[tokio::test]
async fn test_原子性_全有或全无() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [
                {"file_path": "f.txt", "start_line": 1, "new_string": "AAA"},
                {"file_path": "f.txt", "start_line": 99, "new_string": "XXX"}
            ]
        }))
        .await
        .unwrap();
    assert!(result.contains("✗"), "应报告失败: {result}");
    assert!(result.contains("未执行任何编辑"), "应声明未执行: {result}");
    // 文件未被修改
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nbbb\nccc\n",
        "原子性：文件不应被修改"
    );
}

#[tokio::test]
async fn test_原子性_跨文件() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("a.txt"), "aaa\n").unwrap();
    std::fs::write(dir.path().join("b.txt"), "bbb\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [
                {"file_path": "a.txt", "start_line": 1, "new_string": "AAA"},
                {"file_path": "b.txt", "start_line": 99, "new_string": "XXX"}
            ]
        }))
        .await
        .unwrap();
    assert!(result.contains("✗"), "应报告失败: {result}");
    // 两个文件都未被修改
    assert_eq!(
        std::fs::read_to_string(dir.path().join("a.txt")).unwrap(),
        "aaa\n",
        "跨文件原子性：a.txt 不应被修改"
    );
    assert_eq!(
        std::fs::read_to_string(dir.path().join("b.txt")).unwrap(),
        "bbb\n",
        "跨文件原子性：b.txt 不应被修改"
    );
}

// ─── 重叠检测测试 ─────────────────────────────────────────────────────────

#[tokio::test]
async fn test_重叠检测_拒绝() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "a\nb\nc\nd\ne\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [
                {"file_path": "f.txt", "start_line": 2, "end_line": 4, "new_string": "X"},
                {"file_path": "f.txt", "start_line": 3, "end_line": 5, "new_string": "Y"}
            ]
        }))
        .await
        .unwrap();
    assert!(result.contains("重叠"), "应报告重叠: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "a\nb\nc\nd\ne\n",
        "重叠时文件不应被修改"
    );
}

#[tokio::test]
async fn test_重叠_不同文件不检测() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("a.txt"), "aaa\n").unwrap();
    std::fs::write(dir.path().join("b.txt"), "bbb\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [
                {"file_path": "a.txt", "start_line": 1, "end_line": 1, "new_string": "AAA"},
                {"file_path": "b.txt", "start_line": 1, "end_line": 1, "new_string": "BBB"}
            ]
        }))
        .await
        .unwrap();
    assert!(!result.contains("✗"), "不应有失败: {result}");
}

#[tokio::test]
async fn test_重叠_首尾相接不算重叠() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "a\nb\nc\nd\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [
                {"file_path": "f.txt", "start_line": 1, "end_line": 2, "new_string": "X"},
                {"file_path": "f.txt", "start_line": 3, "end_line": 4, "new_string": "Y"}
            ]
        }))
        .await
        .unwrap();
    assert!(!result.contains("✗"), "首尾相接不应报重叠: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "X\nY\n"
    );
}

#[tokio::test]
async fn test_重叠_insert不参与重叠检测() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "a\nb\nc\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [
                {"file_path": "f.txt", "start_line": 2, "end_line": 2, "new_string": "X"},
                {"file_path": "f.txt", "start_line": 2, "action": "insert", "new_string": "INSERTED"}
            ]
        }))
        .await
        .unwrap();
    assert!(!result.contains("重叠"), "insert 不参与重叠检测: {result}");
}

// ─── 反馈格式测试 ──────────────────────────────────────────────────────────

#[tokio::test]
async fn test_反馈_含上下文diff() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "line1\nline2\nline3\nline4\nline5\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool
        .invoke(serde_json::json!({
            "edits": [{
                "file_path": "f.txt",
                "start_line": 3,
                "new_string": "LINE3"
            }]
        }))
        .await
        .unwrap();
    assert!(result.contains("✓"), "应标记成功: {result}");
    assert!(result.contains("|-"), "应包含旧行标记: {result}");
    assert!(result.contains("|+"), "应包含新行标记: {result}");
}

// ─── 基础操作测试 ──────────────────────────────────────────────────────────

#[tokio::test]
async fn test_替换多行() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\nddd\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 2,
            "end_line": 3,
            "new_string": "XXX"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nXXX\nddd\n"
    );
}

#[tokio::test]
async fn test_insert_在行首() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 1,
            "action": "insert",
            "new_string": "NEW"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "NEW\naaa\nbbb\n"
    );
}

#[tokio::test]
async fn test_insert_在末尾() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 3,
            "action": "insert",
            "new_string": "APPENDED"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nbbb\nAPPENDED\n"
    );
}

#[tokio::test]
async fn test_替换整个文件() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 1,
            "end_line": 3,
            "new_string": "brand new"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "brand new\n"
    );
}

#[tokio::test]
async fn test_删除所有行() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 1,
            "end_line": 2,
            "action": "delete",
            "new_string": ""
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        ""
    );
}

#[tokio::test]
async fn test_空文件插入() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 1,
            "action": "insert",
            "new_string": "hello"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "hello"
    );
}

// ─── 多编辑测试 ──────────────────────────────────────────────────────────

#[tokio::test]
async fn test_同文件多编辑从后往前() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "a\nb\nc\nd\ne\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [
            {"file_path": "f.txt", "start_line": 2, "new_string": "BBB"},
            {"file_path": "f.txt", "start_line": 4, "new_string": "DDD"}
        ]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "a\nBBB\nc\nDDD\ne\n"
    );
}

#[tokio::test]
async fn test_跨文件多编辑() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("a.txt"), "aaa\n").unwrap();
    std::fs::write(dir.path().join("b.txt"), "bbb\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [
            {"file_path": "a.txt", "start_line": 1, "new_string": "AAA"},
            {"file_path": "b.txt", "start_line": 1, "new_string": "BBB"}
        ]
    }))
    .await
    .unwrap();
    assert_eq!(std::fs::read_to_string(dir.path().join("a.txt")).unwrap(), "AAA\n");
    assert_eq!(std::fs::read_to_string(dir.path().join("b.txt")).unwrap(), "BBB\n");
}

// ─── 错误处理测试 ──────────────────────────────────────────────────────────

#[tokio::test]
async fn test_行号超出范围() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool.invoke(serde_json::json!({
        "edits": [make_edit("f.txt", 99, "xxx")]
    })).await.unwrap();
    assert!(result.contains("✗"), "应标记失败: {result}");
    assert!(result.contains("超出"), "应报告超出范围: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\n",
        "文件不应被修改"
    );
}

#[tokio::test]
async fn test_文件不存在() {
    let dir = tempfile::tempdir().unwrap();
    let tool = make_tool(&dir);
    let result = tool.invoke(serde_json::json!({
        "edits": [make_edit("ghost.txt", 1, "xxx")]
    })).await;
    let err = result.unwrap_err();
    assert!(err.to_string().contains("不存在"), "应报文件不存在: {err}");
}

#[tokio::test]
async fn test_end_line小于start_line() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 3,
            "end_line": 1,
            "new_string": "xxx"
        }]
    }))
    .await
    .unwrap();
    assert!(result.contains("✗"), "应标记失败: {result}");
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nbbb\n",
        "文件不应被修改"
    );
}

#[tokio::test]
async fn test_start_line为零() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\n").unwrap();
    let tool = make_tool(&dir);
    let result = tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 0,
            "new_string": "xxx"
        }]
    }))
    .await
    .unwrap();
    assert!(result.contains("✗"), "应标记失败: {result}");
}

// ─── CRLF 换行符保留测试 ──────────────────────────────────────────────────

#[tokio::test]
async fn test_crlf_替换保留() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\r\nbbb\r\nccc\r\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [make_edit("f.txt", 2, "BBB")]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\r\nBBB\r\nccc\r\n"
    );
}

#[tokio::test]
async fn test_crlf_insert保留() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\r\nbbb\r\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [{
            "file_path": "f.txt",
            "start_line": 2,
            "action": "insert",
            "new_string": "xxx\nyyy"
        }]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\r\nxxx\r\nyyy\r\nbbb\r\n"
    );
}

#[tokio::test]
async fn test_crlf_delete保留() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\r\nbbb\r\nccc\r\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [make_edit("f.txt", 2, "")]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\r\nccc\r\n"
    );
}

#[tokio::test]
async fn test_lf_不受影响() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("f.txt"), "aaa\nbbb\nccc\n").unwrap();
    let tool = make_tool(&dir);
    tool.invoke(serde_json::json!({
        "edits": [make_edit("f.txt", 2, "BBB")]
    }))
    .await
    .unwrap();
    assert_eq!(
        std::fs::read_to_string(dir.path().join("f.txt")).unwrap(),
        "aaa\nBBB\nccc\n"
    );
}
```

- [ ] **Step 2: 运行全部测试**

Run: `cargo test -p peri-middlewares --lib -- tools::filesystem::line_edit::tests 2>&1`
Expected: ALL TESTS PASS

- [ ] **Step 3: Commit**

```bash
git add peri-middlewares/src/tools/filesystem/line_edit_test.rs
git commit -m "test(lineedit): rewrite all tests for V2 with action/expected_lines/atomicity

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>"
```

---

### Task 4: 更新 CLAUDE.md 和压力测试文件

**Files:**
- Modify: `CLAUDE.md` (lineEdit beta 描述)
- Modify: `prompts/lineedit_stress_test.txt` (移除 start_word/end_word 注释)

- [ ] **Step 1: 更新 CLAUDE.md 中 lineEdit beta 描述**

将 CLAUDE.md 第 204 行的 lineEdit 描述从：
```
| `lineEdit` | 启用行号编辑模式——Edit 替换为 LineEdit（基于行号的精确编辑，支持 start_word/end_word 行内定位、多编辑从后往前应用、跨文件分组、best-effort 失败策略） |
```
改为：
```
| `lineEdit` | 启用行号编辑模式——Edit 替换为 LineEdit（基于行号的精确编辑，action 枚举语义、expected_lines 内容验证、全有或全无原子性、上下文 diff 反馈） |
```

- [ ] **Step 2: 更新压力测试文件注释**

修改 `prompts/lineedit_stress_test.txt`，将 `# 已知陷阱` 注释块中的 start_word/end_word 相关内容替换为 V2 说明：

```
# 已知特性（LineEdit V2）：
#   1. 使用 action 字段指定操作："replace" / "insert" / "delete"
#   2. expected_lines 用于验证目标内容，不匹配时警告但继续执行
#   3. 同一文件的多编辑必须不重叠，否则全部拒绝
#   4. 所有编辑全有或全无——任一失败则全部不写入
```

- [ ] **Step 3: Commit**

```bash
git add CLAUDE.md prompts/lineedit_stress_test.txt
git commit -m "docs: update CLAUDE.md and stress test file for LineEdit V2

Co-Authored-By: glm-5.1 <zai-org@claude-code-best.win>"
```

---

### Task 5: 全量构建和测试验证

**Files:** 无新增/修改

- [ ] **Step 1: 全量构建**

Run: `cargo build 2>&1 | tail -5`
Expected: 编译成功，无错误

- [ ] **Step 2: 全量测试**

Run: `cargo test -p peri-middlewares --lib 2>&1 | tail -20`
Expected: ALL TESTS PASS

- [ ] **Step 3: clippy 检查**

Run: `cargo clippy -p peri-middlewares 2>&1 | tail -10`
Expected: 无 warning/error（或仅有与本次改动无关的既有 warning）
