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 — patch by (line,hash) anchor from ctx_read(anchored)/ctx_search(anchored=true).\n\
26             Ops: set_line(line,hash,new_text) | replace_lines(start_line/hash,end_line/hash,new_text) |\n\
27             insert_after(line,hash,new_text) | delete(line,hash or start/end range) |\n\
28             replace_symbol(name,new_body) | create(new_text) | replace_all(find,replace,dry_run).\n\
29             Batch: ops:[{…}]. Stale anchor → CONFLICT with fresh anchors.",
30            json!({
31                "type": "object",
32                "properties": {
33                    "path": { "type": "string" },
34                    "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_symbol", "create", "replace_all"] },
35                    "line": { "type": "integer" },
36                    "hash": { "type": "string" },
37                    "start_line": { "type": "integer" },
38                    "start_hash": { "type": "string" },
39                    "end_line": { "type": "integer" },
40                    "end_hash": { "type": "string" },
41                    "new_text": { "type": "string" },
42                    "name": { "type": "string" },
43                    "new_body": { "type": "string" },
44                    "find": { "type": "string", "description": "Literal text to find (replace_all)" },
45                    "replace": { "type": "string", "description": "Replacement text (replace_all)" },
46                    "dry_run": { "type": "boolean", "description": "Preview only, do not write (replace_all)" },
47                    "ops": { "type": "array", "items": { "type": "object" } }
48                },
49                "required": ["path"]
50            }),
51        )
52    }
53
54    fn handle(
55        &self,
56        args: &Map<String, Value>,
57        ctx: &ToolContext,
58    ) -> Result<ToolOutput, ErrorData> {
59        // replace_symbol is a whole-symbol rewrite — delegate to the LSP/IDE-aware
60        // ctx_refactor so there is one symbol-edit implementation (epic #1008).
61        if crate::tools::ctx_patch::is_replace_symbol(args) {
62            return delegate_replace_symbol(args, ctx);
63        }
64
65        // #825: replace_all short-circuits before anchor parsing.
66        if get_str(args, "op").as_deref() == Some("replace_all") {
67            return handle_replace_all(args, ctx);
68        }
69
70        let path = require_resolved_path(ctx, args, "path")?;
71
72        let ops = crate::tools::ctx_patch::parse_ops(args)
73            .map_err(|e| ErrorData::invalid_params(e, None))?;
74
75        let expected_md5 = get_str(args, "expected_md5");
76        let backup = get_bool(args, "backup").unwrap_or(false);
77        let backup_path = get_str(args, "backup_path")
78            .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
79        let evidence = get_bool(args, "evidence").unwrap_or(true);
80        let diff_max_lines = get_int(args, "diff_max_lines")
81            .and_then(|v| usize::try_from(v.max(0)).ok())
82            .unwrap_or(200);
83        let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
84        let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
85
86        let patch_params = crate::tools::ctx_patch::PatchParams {
87            path: path.clone(),
88            ops,
89            expected_md5,
90            backup,
91            backup_path,
92            evidence,
93            diff_max_lines,
94            allow_lossy_utf8,
95            validate_syntax,
96        };
97
98        tokio::task::block_in_place(|| {
99            let cache_lock = ctx
100                .cache
101                .as_ref()
102                .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
103            let rt = tokio::runtime::Handle::current();
104
105            // Serialize edits to the SAME file via the shared per-file lock (the
106            // same registry ctx_edit/ctx_read use), so anchored and str_replace
107            // edits of one file never interleave (issue #320). Correctness across
108            // processes still rests on the TOCTOU preimage guard + atomic rename.
109            let file_lock = crate::core::path_locks::per_file_lock(&path);
110            let _file_guard = {
111                let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
112                loop {
113                    if let Ok(guard) = file_lock.try_lock() {
114                        break guard;
115                    }
116                    if std::time::Instant::now() >= deadline {
117                        return Err(ErrorData::internal_error(
118                            format!(
119                                "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
120                            ),
121                            None,
122                        ));
123                    }
124                    std::thread::sleep(std::time::Duration::from_millis(20));
125                }
126            };
127
128            let last_mode = match rt.block_on(tokio::time::timeout(
129                std::time::Duration::from_secs(5),
130                cache_lock.read(),
131            )) {
132                Ok(cache) => cache
133                    .get(&path)
134                    .map(|e| e.last_mode.clone())
135                    .unwrap_or_default(),
136                Err(_) => String::new(),
137            };
138
139            // Heavy disk I/O — no global cache lock held here.
140            let (output, effect) = crate::tools::ctx_patch::run_io(&patch_params, &last_mode);
141
142            crate::tools::ctx_patch::record_outcome(&patch_params, &last_mode, &output, &effect);
143
144            if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
145                match rt.block_on(tokio::time::timeout(
146                    std::time::Duration::from_secs(5),
147                    cache_lock.write(),
148                )) {
149                    Ok(mut cache) => {
150                        crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
151                    }
152                    Err(_) => {
153                        tracing::warn!(
154                            "ctx_patch: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
155                        );
156                    }
157                }
158            }
159
160            if let Some(session_lock) = ctx.session.as_ref() {
161                let guard = rt.block_on(tokio::time::timeout(
162                    std::time::Duration::from_secs(5),
163                    session_lock.write(),
164                ));
165                if let Ok(mut session) = guard {
166                    session.mark_modified(&path);
167                }
168            }
169
170            Ok(ToolOutput {
171                text: output,
172                original_tokens: 0,
173                saved_tokens: 0,
174                mode: None,
175                path: Some(path),
176                changed: false,
177                shell_outcome: None,
178            })
179        })
180    }
181}
182
183/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
184/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
185/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
186/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
187fn delegate_replace_symbol(
188    args: &Map<String, Value>,
189    ctx: &ToolContext,
190) -> Result<ToolOutput, ErrorData> {
191    let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
192        .map_err(|e| ErrorData::invalid_params(e, None))?;
193
194    // Resolve `path` at the boundary when given (the name route resolves its own
195    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
196    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
197    let has_path = args.get("path").and_then(Value::as_str).is_some();
198    let abs_path = if has_path {
199        require_resolved_path(ctx, args, "path")?
200    } else {
201        String::new()
202    };
203
204    let args_value = Value::Object(refactor_args);
205    let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
206    let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
207
208    Ok(ToolOutput {
209        text: result,
210        original_tokens: 0,
211        saved_tokens: 0,
212        mode: Some("replace_symbol".to_string()),
213        path: get_str(args, "path"),
214        changed,
215        shell_outcome: None,
216    })
217}
218
219/// #825: Bulk literal find-and-replace — no anchors needed.
220fn handle_replace_all(
221    args: &Map<String, Value>,
222    ctx: &ToolContext,
223) -> Result<ToolOutput, ErrorData> {
224    let path = require_resolved_path(ctx, args, "path")?;
225    let find = get_str(args, "find")
226        .filter(|s| !s.is_empty())
227        .ok_or_else(|| ErrorData::invalid_params("replace_all requires non-empty 'find'", None))?;
228    let replace = get_str(args, "replace").unwrap_or_default();
229    let dry_run = get_bool(args, "dry_run").unwrap_or(false);
230
231    let content = std::fs::read_to_string(&path)
232        .map_err(|e| ErrorData::internal_error(format!("cannot read {path}: {e}"), None))?;
233
234    let count = content.matches(find.as_str()).count();
235    if count == 0 {
236        return Ok(ToolOutput::simple(format!(
237            "No matches for {find:?} in {path}"
238        )));
239    }
240
241    if dry_run {
242        return Ok(ToolOutput::simple(format!(
243            "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
244        )));
245    }
246
247    let file_lock = crate::core::path_locks::per_file_lock(&path);
248    let _guard = file_lock
249        .lock()
250        .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
251
252    let new_content = content.replace(find.as_str(), &replace);
253    crate::config_io::write_atomic(std::path::Path::new(&path), &new_content)
254        .map_err(|e| ErrorData::internal_error(format!("write failed: {e}"), None))?;
255
256    if let Some(cache) = ctx.cache.as_ref() {
257        let rt = tokio::runtime::Handle::current();
258        if let Ok(mut c) = rt.block_on(tokio::time::timeout(
259            std::time::Duration::from_secs(2),
260            cache.write(),
261        )) {
262            c.invalidate(&path);
263        }
264    }
265
266    Ok(ToolOutput::simple(format!(
267        "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
268    )))
269}