Skip to main content

lean_ctx/tools/registered/
ctx_patch.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6    McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxPatchTool;
11
12impl McpTool for CtxPatchTool {
13    fn name(&self) -> &'static str {
14        "ctx_patch"
15    }
16
17    fn tool_def(&self) -> Tool {
18        tool_def(
19            "ctx_patch",
20            "Hash-anchored edit — edit by line ANCHOR, not by reproducing old text.\n\
21             First read with ctx_read(mode=\"anchored\") to get N:hh|line anchors, then patch by (line, hash).\n\
22             op=set_line replaces one line; replace_lines a range; insert_after adds after a line (line 0 = top); delete removes.\n\
23             op=replace_symbol rewrites a whole symbol body by name (or path+line) via ctx_refactor — pass new_body.\n\
24             new_text=\"\" deletes the line. Batch many line edits via ops:[…] — all validated against the same file, applied all-or-nothing.\n\
25             A stale anchor is REJECTED with fresh anchors to retry — no partial writes. Prefer this over native str_replace/Edit for reliability.",
26            json!({
27                "type": "object",
28                "properties": {
29                    "path": { "type": "string", "description": "File path to edit" },
30                    "op": { "type": "string", "description": "set_line | replace_lines | insert_after | delete | replace_symbol" },
31                    "line": { "type": "integer", "description": "1-based line (set_line/insert_after/delete; line 0 = top for insert_after)" },
32                    "hash": { "type": "string", "description": "Anchor hash hh from ctx_read(mode=anchored) for `line`" },
33                    "start_line": { "type": "integer", "description": "Range start (replace_lines/delete)" },
34                    "start_hash": { "type": "string", "description": "Anchor hash for start_line" },
35                    "end_line": { "type": "integer", "description": "Range end, inclusive (replace_lines/delete)" },
36                    "end_hash": { "type": "string", "description": "Anchor hash for end_line" },
37                    "new_text": { "type": "string", "description": "Replacement text; \"\" deletes (set_line/replace_lines)" },
38                    "name": { "type": "string", "description": "Symbol path for replace_symbol (qualified or bare)" },
39                    "new_body": { "type": "string", "description": "Full replacement declaration for replace_symbol" },
40                    "ops": {
41                        "type": "array",
42                        "description": "Batch-atomic edits; each item is {op, line/start_line…, hash…, new_text}",
43                        "items": { "type": "object" }
44                    },
45                    "expected_md5": { "type": "string", "description": "Optional whole-file BLAKE3 guard (postimage md5 from a prior edit)" },
46                    "backup": { "type": "boolean", "description": "Write a .bak before editing", "default": false },
47                    "validate_syntax": { "type": "boolean", "description": "Reject edits that break a cleanly-parsing file (tree-sitter)", "default": true },
48                    "evidence": { "type": "boolean", "description": "Append a redacted, bounded diff", "default": true }
49                },
50                "required": ["path"]
51            }),
52        )
53    }
54
55    fn handle(
56        &self,
57        args: &Map<String, Value>,
58        ctx: &ToolContext,
59    ) -> Result<ToolOutput, ErrorData> {
60        // replace_symbol is a whole-symbol rewrite — delegate to the LSP/IDE-aware
61        // ctx_refactor so there is one symbol-edit implementation (epic #1008).
62        if crate::tools::ctx_patch::is_replace_symbol(args) {
63            return delegate_replace_symbol(args, ctx);
64        }
65
66        let path = require_resolved_path(ctx, args, "path")?;
67
68        let ops = crate::tools::ctx_patch::parse_ops(args)
69            .map_err(|e| ErrorData::invalid_params(e, None))?;
70
71        let expected_md5 = get_str(args, "expected_md5");
72        let backup = get_bool(args, "backup").unwrap_or(false);
73        let backup_path = get_str(args, "backup_path")
74            .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
75        let evidence = get_bool(args, "evidence").unwrap_or(true);
76        let diff_max_lines = get_int(args, "diff_max_lines")
77            .and_then(|v| usize::try_from(v.max(0)).ok())
78            .unwrap_or(200);
79        let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
80        let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
81
82        let patch_params = crate::tools::ctx_patch::PatchParams {
83            path: path.clone(),
84            ops,
85            expected_md5,
86            backup,
87            backup_path,
88            evidence,
89            diff_max_lines,
90            allow_lossy_utf8,
91            validate_syntax,
92        };
93
94        tokio::task::block_in_place(|| {
95            let cache_lock = ctx
96                .cache
97                .as_ref()
98                .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
99            let rt = tokio::runtime::Handle::current();
100
101            // Serialize edits to the SAME file via the shared per-file lock (the
102            // same registry ctx_edit/ctx_read use), so anchored and str_replace
103            // edits of one file never interleave (issue #320). Correctness across
104            // processes still rests on the TOCTOU preimage guard + atomic rename.
105            let file_lock = crate::core::path_locks::per_file_lock(&path);
106            let _file_guard = {
107                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
108                loop {
109                    if let Ok(guard) = file_lock.try_lock() {
110                        break guard;
111                    }
112                    if std::time::Instant::now() >= deadline {
113                        return Err(ErrorData::internal_error(
114                            format!(
115                                "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
116                            ),
117                            None,
118                        ));
119                    }
120                    std::thread::sleep(std::time::Duration::from_millis(20));
121                }
122            };
123
124            let last_mode = match rt.block_on(tokio::time::timeout(
125                std::time::Duration::from_secs(5),
126                cache_lock.read(),
127            )) {
128                Ok(cache) => cache
129                    .get(&path)
130                    .map(|e| e.last_mode.clone())
131                    .unwrap_or_default(),
132                Err(_) => String::new(),
133            };
134
135            // Heavy disk I/O — no global cache lock held here.
136            let (output, effect) = crate::tools::ctx_patch::run_io(&patch_params, &last_mode);
137
138            crate::tools::ctx_patch::record_outcome(&patch_params, &last_mode, &output, &effect);
139
140            if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
141                match rt.block_on(tokio::time::timeout(
142                    std::time::Duration::from_secs(5),
143                    cache_lock.write(),
144                )) {
145                    Ok(mut cache) => {
146                        crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
147                    }
148                    Err(_) => {
149                        tracing::warn!(
150                            "ctx_patch: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
151                        );
152                    }
153                }
154            }
155
156            if let Some(session_lock) = ctx.session.as_ref() {
157                let guard = rt.block_on(tokio::time::timeout(
158                    std::time::Duration::from_secs(5),
159                    session_lock.write(),
160                ));
161                if let Ok(mut session) = guard {
162                    session.mark_modified(&path);
163                }
164            }
165
166            Ok(ToolOutput {
167                text: output,
168                original_tokens: 0,
169                saved_tokens: 0,
170                mode: None,
171                path: Some(path),
172                changed: false,
173                shell_outcome: None,
174            })
175        })
176    }
177}
178
179/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
180/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
181/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
182/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
183fn delegate_replace_symbol(
184    args: &Map<String, Value>,
185    ctx: &ToolContext,
186) -> Result<ToolOutput, ErrorData> {
187    let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
188        .map_err(|e| ErrorData::invalid_params(e, None))?;
189
190    // Resolve `path` at the boundary when given (the name route resolves its own
191    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
192    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
193    let has_path = args.get("path").and_then(Value::as_str).is_some();
194    let abs_path = if has_path {
195        require_resolved_path(ctx, args, "path")?
196    } else {
197        String::new()
198    };
199
200    let args_value = Value::Object(refactor_args);
201    let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
202    let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
203
204    Ok(ToolOutput {
205        text: result,
206        original_tokens: 0,
207        saved_tokens: 0,
208        mode: Some("replace_symbol".to_string()),
209        path: get_str(args, "path"),
210        changed,
211        shell_outcome: None,
212    })
213}