lean_ctx/tools/registered/
ctx_patch.rs1use 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 {
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 if crate::tools::ctx_patch::is_replace_symbol(args) {
62 return delegate_replace_symbol(args, ctx);
63 }
64
65 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 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 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
183fn 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 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
219fn 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}