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    // Schema diet (#576 pattern): the advertised surface carries only the
18    // functional teaching (anchor source, op routing, batch atomicity).
19    // Handler-only params stay supported but unadvertised: expected_md5,
20    // backup, backup_path, validate_syntax, evidence, diff_max_lines,
21    // allow_lossy_utf8 — same hidden-params contract as ctx_edit.
22    fn tool_def(&self) -> Tool {
23        tool_def(
24            "ctx_patch",
25            "Hash-anchored edit. ALWAYS ctx_read(mode=\"anchored\") first → lines like 42:a1b2|code (line=42, hash=a1b2).\n\
26             replace_lines(path, start_line, start_hash, end_line, end_hash, new_text) — ALL required.\n\
27             set_line(path, line, hash, new_text) | insert_after(path, line, hash, new_text) | delete(path, line, hash).\n\
28             replace_symbol(path, name, new_body) | create(path, new_text) | replace_all(path, find, replace, dry_run?).\n\
29             Batch: ops:[{op, path, ...}] — not replace_symbol/replace_all.\n\
30             CONFLICT = stale anchors, re-read. Line-only patch (no hash) → error.",
31            json!({
32                "type": "object",
33                "properties": {
34                    "path": { "type": "string" },
35                    "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_symbol", "create", "replace_all"] },
36                    "line": { "type": "integer" },
37                    "hash": { "type": "string" },
38                    "start_line": { "type": "integer" },
39                    "start_hash": { "type": "string" },
40                    "end_line": { "type": "integer" },
41                    "end_hash": { "type": "string" },
42                    "new_text": { "type": "string" },
43                    "name": { "type": "string" },
44                    "new_body": { "type": "string" },
45                    "find": { "type": "string", "description": "Literal text to find (replace_all)" },
46                    "replace": { "type": "string", "description": "Replacement text (replace_all)" },
47                    "dry_run": { "type": "boolean", "description": "Preview only, do not write (replace_all)" },
48                    "ops": { "type": "array", "items": { "type": "object" } }
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        // #825: replace_all short-circuits before anchor parsing.
67        if get_str(args, "op").as_deref() == Some("replace_all") {
68            return handle_replace_all(args, ctx);
69        }
70
71        let path = require_resolved_path(ctx, args, "path")?;
72
73        let ops = crate::tools::ctx_patch::parse_ops(args)
74            .map_err(|e| ErrorData::invalid_params(e, None))?;
75
76        let expected_md5 = get_str(args, "expected_md5");
77        let backup = get_bool(args, "backup").unwrap_or(false);
78        let backup_path = get_str(args, "backup_path")
79            .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
80        let evidence = get_bool(args, "evidence").unwrap_or(true);
81        let diff_max_lines = get_int(args, "diff_max_lines")
82            .and_then(|v| usize::try_from(v.max(0)).ok())
83            .unwrap_or(200);
84        let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
85        let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
86
87        let patch_params = crate::tools::ctx_patch::PatchParams {
88            path: path.clone(),
89            ops,
90            expected_md5,
91            backup,
92            backup_path,
93            evidence,
94            diff_max_lines,
95            allow_lossy_utf8,
96            validate_syntax,
97        };
98
99        tokio::task::block_in_place(|| {
100            let cache_lock = ctx
101                .cache
102                .as_ref()
103                .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
104            let rt = tokio::runtime::Handle::current();
105
106            // Serialize edits to the SAME file via the shared per-file lock (the
107            // same registry ctx_edit/ctx_read use), so anchored and str_replace
108            // edits of one file never interleave (issue #320). Correctness across
109            // processes still rests on the TOCTOU preimage guard + atomic rename.
110            let file_lock = crate::core::path_locks::per_file_lock(&path);
111            let _file_guard = {
112                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
113                loop {
114                    if let Ok(guard) = file_lock.try_lock() {
115                        break guard;
116                    }
117                    if std::time::Instant::now() >= deadline {
118                        return Err(ErrorData::internal_error(
119                            format!(
120                                "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
121                            ),
122                            None,
123                        ));
124                    }
125                    std::thread::sleep(std::time::Duration::from_millis(20));
126                }
127            };
128
129            let last_mode = match rt.block_on(tokio::time::timeout(
130                std::time::Duration::from_secs(5),
131                cache_lock.read(),
132            )) {
133                Ok(cache) => cache
134                    .get(&path)
135                    .map(|e| e.last_mode.clone())
136                    .unwrap_or_default(),
137                Err(_) => String::new(),
138            };
139
140            // Heavy disk I/O — no global cache lock held here.
141            let (output, effect) = crate::tools::ctx_patch::run_io(&patch_params, &last_mode);
142
143            crate::tools::ctx_patch::record_outcome(&patch_params, &last_mode, &output, &effect);
144
145            if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
146                match rt.block_on(tokio::time::timeout(
147                    std::time::Duration::from_secs(5),
148                    cache_lock.write(),
149                )) {
150                    Ok(mut cache) => {
151                        crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
152                    }
153                    Err(_) => {
154                        tracing::warn!(
155                            "ctx_patch: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
156                        );
157                    }
158                }
159            }
160
161            if let Some(session_lock) = ctx.session.as_ref() {
162                let guard = rt.block_on(tokio::time::timeout(
163                    std::time::Duration::from_secs(5),
164                    session_lock.write(),
165                ));
166                if let Ok(mut session) = guard {
167                    session.mark_modified(&path);
168                }
169            }
170
171            Ok(ToolOutput {
172                text: output,
173                original_tokens: 0,
174                saved_tokens: 0,
175                mode: None,
176                path: Some(path),
177                changed: false,
178                shell_outcome: None,
179                content_blocks: None,
180            })
181        })
182    }
183}
184
185/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
186/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
187/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
188/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
189fn delegate_replace_symbol(
190    args: &Map<String, Value>,
191    ctx: &ToolContext,
192) -> Result<ToolOutput, ErrorData> {
193    let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
194        .map_err(|e| ErrorData::invalid_params(e, None))?;
195
196    // Resolve `path` at the boundary when given (the name route resolves its own
197    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
198    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
199    let has_path = args.get("path").and_then(Value::as_str).is_some();
200    let abs_path = if has_path {
201        require_resolved_path(ctx, args, "path")?
202    } else {
203        String::new()
204    };
205
206    let args_value = Value::Object(refactor_args);
207    let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
208    let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
209
210    Ok(ToolOutput {
211        text: result,
212        original_tokens: 0,
213        saved_tokens: 0,
214        mode: Some("replace_symbol".to_string()),
215        path: get_str(args, "path"),
216        changed,
217        shell_outcome: None,
218        content_blocks: None,
219    })
220}
221
222/// #879: resolve `find`/`replace` for replace_all, failing *closed* on the
223/// destructive path. Historically a missing `replace` defaulted to "" — so a
224/// typo'd replacement key (`new_string=`/`new_text=` carried over from the other
225/// ops) meant "delete every match" and still reported success. Now: reject
226/// replacement keys that belong to other ops, and require `replace` to be
227/// present. An empty deletion must be opted into explicitly with `replace=""`.
228fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
229    let find = get_str(args, "find")
230        .filter(|s| !s.is_empty())
231        .ok_or("replace_all requires non-empty 'find'")?;
232
233    for foreign in ["new_text", "new_string", "old_string", "new_body"] {
234        if args.contains_key(foreign) {
235            return Err(format!(
236                "replace_all names its replacement 'replace', not '{foreign}' — rename it \
237                 (an unrecognized replacement key would silently delete every match)"
238            ));
239        }
240    }
241
242    let replace = args
243        .get("replace")
244        .and_then(Value::as_str)
245        .map(String::from)
246        .ok_or(
247            "replace_all requires 'replace' (the replacement text); pass replace=\"\" \
248             explicitly to delete every match",
249        )?;
250
251    Ok((find, replace))
252}
253
254/// #825: Bulk literal find-and-replace — no anchors needed.
255fn handle_replace_all(
256    args: &Map<String, Value>,
257    ctx: &ToolContext,
258) -> Result<ToolOutput, ErrorData> {
259    let path = require_resolved_path(ctx, args, "path")?;
260    let (find, replace) =
261        resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
262    let dry_run = get_bool(args, "dry_run").unwrap_or(false);
263
264    let content = std::fs::read_to_string(&path)
265        .map_err(|e| ErrorData::internal_error(format!("cannot read {path}: {e}"), None))?;
266
267    let count = content.matches(find.as_str()).count();
268    if count == 0 {
269        return Ok(ToolOutput::simple(format!(
270            "No matches for {find:?} in {path}"
271        )));
272    }
273
274    if dry_run {
275        return Ok(ToolOutput::simple(format!(
276            "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
277        )));
278    }
279
280    let file_lock = crate::core::path_locks::per_file_lock(&path);
281    let _guard = file_lock
282        .lock()
283        .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
284
285    let new_content = content.replace(find.as_str(), &replace);
286    crate::config_io::write_atomic(std::path::Path::new(&path), &new_content)
287        .map_err(|e| ErrorData::internal_error(format!("write failed: {e}"), None))?;
288
289    if let Some(cache) = ctx.cache.as_ref() {
290        let rt = tokio::runtime::Handle::current();
291        if let Ok(mut c) = rt.block_on(tokio::time::timeout(
292            std::time::Duration::from_secs(2),
293            cache.write(),
294        )) {
295            c.invalidate(&path);
296        }
297    }
298
299    Ok(ToolOutput::simple(format!(
300        "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
301    )))
302}
303
304#[cfg(test)]
305mod replace_all_tests {
306    use super::*;
307    use serde_json::json;
308
309    fn obj(v: Value) -> Map<String, Value> {
310        match v {
311            Value::Object(m) => m,
312            _ => panic!("expected object"),
313        }
314    }
315
316    #[test]
317    fn resolves_find_and_replace() {
318        let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
319        assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
320    }
321
322    #[test]
323    fn explicit_empty_replace_is_a_deletion() {
324        let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
325        assert_eq!(r, "");
326    }
327
328    #[test]
329    fn missing_replace_is_rejected_not_silent_delete() {
330        let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
331        assert!(err.contains("requires 'replace'"), "got: {err}");
332    }
333
334    #[test]
335    fn foreign_replacement_key_is_rejected() {
336        for key in ["new_string", "new_text", "old_string", "new_body"] {
337            let err = resolve_find_replace(&obj(json!({"find": "a", key: "b"}))).unwrap_err();
338            assert!(
339                err.contains(key),
340                "must name the offending key {key}: {err}"
341            );
342        }
343    }
344
345    #[test]
346    fn empty_find_is_rejected() {
347        let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
348        assert!(err.contains("find"), "got: {err}");
349    }
350}