1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
8 tool_execution_error,
9};
10use lean_ctx::tool_defs::tool_def;
11
12pub struct CtxPatchTool;
13
14pub(crate) fn host_draws_diffs(ctx: &ToolContext) -> bool {
18 static MOD_PRESENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
19 let claude = ctx
20 .client_name
21 .as_ref()
22 .and_then(|name| {
23 name.try_read()
24 .ok()
25 .map(|n| n.to_ascii_lowercase().contains("claude"))
26 })
27 .unwrap_or(false);
28 claude
29 && *MOD_PRESENT.get_or_init(|| {
30 std::env::var_os("CLAUDE_CONFIG_DIR")
31 .map(std::path::PathBuf::from)
32 .or_else(|| dirs::home_dir().map(|home| home.join(".claude")))
33 .is_some_and(|dir| {
34 !lean_ctx_hooks::hooks::agents::claude_mod::cached_versions(&dir).is_empty()
35 })
36 })
37}
38
39impl McpTool for CtxPatchTool {
40 fn name(&self) -> &'static str {
41 "ctx_patch"
42 }
43
44 fn tool_def(&self) -> Tool {
51 tool_def(
52 "ctx_patch",
53 "Safe file edit, project-relative paths. replace_unique(path,old_text,new_text): \
54 exact unique replacement, needs no anchors. Line ops take line+hash from \
55 ctx_read(mode=\"anchored:N-M\"); CONFLICT means re-read. ops[] batch \
56 (cross-file, incl. replace_unique/replace_symbol); create and replace_all only \
57 as top-level calls. A 0-byte file: create, or replace_unique with old_text=\"\".",
58 json!({
59 "type": "object",
60 "properties": {
61 "path": { "type": "string" },
62 "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_unique", "replace_symbol", "create", "replace_all"] },
63 "line": { "type": "integer" },
64 "hash": { "type": "string" },
65 "start_line": { "type": "integer" },
66 "start_hash": { "type": "string" },
67 "end_line": { "type": "integer" },
68 "end_hash": { "type": "string" },
69 "new_text": { "type": "string" },
70 "old_text": { "type": "string" },
71 "name": { "type": "string" },
72 "find": { "type": "string" },
73 "replace": { "type": "string" },
74 "dry_run": { "type": "boolean" },
75 "ops": { "type": "array", "items": { "type": "object" } }
76 },
77 "allOf": [
85 { "if": { "properties": { "op": { "const": "set_line" } }, "required": ["op"] },
86 "then": { "required": ["op", "line", "hash", "new_text"] } },
87 { "if": { "properties": { "op": { "const": "replace_lines" } }, "required": ["op"] },
88 "then": { "required": ["op", "start_line", "start_hash", "end_line", "end_hash", "new_text"] } },
89 { "if": { "properties": { "op": { "const": "replace_unique" } }, "required": ["op"] },
90 "then": { "required": ["op", "old_text", "new_text"] } },
91 { "if": { "properties": { "op": { "const": "replace_symbol" } }, "required": ["op"] },
92 "then": { "required": ["op", "new_text"] } },
93 { "if": { "properties": { "op": { "const": "create" } }, "required": ["op"] },
94 "then": { "required": ["op", "new_text"] } },
95 { "if": { "properties": { "op": { "const": "replace_all" } }, "required": ["op"] },
96 "then": { "required": ["op", "find", "replace"] } }
97 ]
98 }),
99 )
100 }
101
102 fn handle(
103 &self,
104 args: &Map<String, Value>,
105 ctx: &ToolContext,
106 ) -> Result<ToolOutput, ErrorData> {
107 crate::tools::claims_gate::scoped(ctx, || handle_patch(args, ctx))
110 }
111}
112
113fn handle_patch(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
114 let inferred = infer_replace_unique(args);
115 let args = inferred.as_ref().unwrap_or(args);
116
117 if crate::tools::ctx_patch::is_replace_symbol(args) {
120 return delegate_replace_symbol(args, ctx);
121 }
122
123 if get_str(args, "op").as_deref() == Some("replace_unique") {
124 return delegate_replace_unique(args, ctx);
125 }
126
127 if get_str(args, "op").as_deref() == Some("replace_all") {
129 return handle_replace_all(args, ctx);
130 }
131
132 if let Some(arr) = args.get("ops").and_then(Value::as_array) {
136 for (i, v) in arr.iter().enumerate() {
138 if let Some(kind) = is_unbatchable_op(v) {
139 return Err(ErrorData::invalid_params(
140 format!(
141 "ops[{i}]: {kind} cannot be batched in ops[] \
142 — send it as a separate top-level ctx_patch call"
143 ),
144 None,
145 ));
146 }
147 }
148 if arr.iter().any(|v| delegated_op_kind(v).is_some()) {
149 return handle_mixed_batch(args, arr, ctx);
150 }
151 }
152
153 handle_anchored(args, ctx)
154}
155
156fn handle_anchored(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
160 if get_bool(args, "dry_run").unwrap_or(false) {
161 let path = get_str(args, "path").unwrap_or_default();
162 return Ok(ToolOutput::simple(format!(
163 "DRY RUN: ctx_patch would apply anchor-based ops to {path}"
164 )));
165 }
166
167 let expected_blake3 =
170 get_str(args, "expected_blake3").or_else(|| get_str(args, "expected_md5"));
171 let backup = get_bool(args, "backup").unwrap_or(false);
172 let backup_path = get_str(args, "backup_path")
173 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p))
174 .map(|p| lean_ctx::core::workspace_authority::confine(&p, &ctx.project_root, true))
175 .transpose()
176 .map_err(|error| ErrorData::invalid_params(error, None))?;
177 let evidence = get_bool(args, "evidence").unwrap_or(false);
179 let diff_max_lines = get_int(args, "diff_max_lines")
180 .and_then(|v| usize::try_from(v.max(0)).ok())
181 .unwrap_or(200);
182 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
183 let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
184
185 let groups = plan_groups(args, ctx)?;
189 validate_cross_file_options(args, groups.len())?;
190 let output_path = (groups.len() == 1).then(|| groups[0].0.clone());
191 let label_parts = groups.len() > 1;
194 let diff_artifact = host_draws_diffs(ctx);
195
196 let mut texts = Vec::with_capacity(groups.len());
197 for (path, ops) in groups {
198 let patch_params = crate::tools::ctx_patch::PatchParams {
199 path: path.clone(),
200 ops,
201 expected_blake3: expected_blake3.clone(),
202 backup,
203 backup_path: backup_path.clone(),
204 evidence,
205 diff_artifact,
206 diff_max_lines,
207 allow_lossy_utf8,
208 validate_syntax,
209 };
210 let output =
211 apply_one(ctx, &patch_params).map_err(|error| with_prior_edits(error, &texts))?;
212 texts.push(if label_parts {
213 format!("[{}]\n{output}", receipt_label(&path, &ctx.project_root))
214 } else {
215 output
216 });
217 }
218
219 Ok(ToolOutput {
220 text: texts.join("\n\n"),
221 original_tokens: 0,
222 saved_tokens: 0,
223 mode: None,
224 path: output_path,
225 changed: false,
226 shell_outcome: None,
227 content_blocks: None,
228 })
229}
230
231fn infer_replace_unique(args: &Map<String, Value>) -> Option<Map<String, Value>> {
241 fn is_text_replacement(obj: &Map<String, Value>) -> bool {
242 let text = |keys: [&str; 2]| keys.iter().find_map(|key| obj.get(*key)?.as_str());
243 !obj.contains_key("op")
244 && text(["old_text", "old_string"]).is_some_and(|old| !old.is_empty())
245 && text(["new_text", "new_string"]).is_some()
246 }
247 fn alias(obj: &mut Map<String, Value>, key: &str, aliases: &[&str]) -> bool {
249 if obj.contains_key(key) {
250 return false;
251 }
252 let Some(value) = aliases
253 .iter()
254 .find_map(|alias| obj.get(*alias).filter(|v| v.is_string()).cloned())
255 else {
256 return false;
257 };
258 obj.insert(key.to_string(), value);
259 true
260 }
261 fn repair(obj: &mut Map<String, Value>) -> bool {
262 let mut changed = false;
263 if is_text_replacement(obj) {
264 obj.insert("op".into(), json!("replace_unique"));
265 changed = true;
266 }
267 match obj.get("op").and_then(Value::as_str) {
268 Some("replace_unique") => {
269 changed |= alias(obj, "old_text", &["old_string", "find"]);
270 changed |= alias(obj, "new_text", &["new_string", "replace"]);
271 }
272 Some("create") => changed |= alias(obj, "new_text", &["new_string", "content"]),
273 Some("set_line" | "replace_lines" | "insert_after") => {
274 changed |= alias(obj, "new_text", &["new_string"]);
275 }
276 _ => {}
277 }
278 changed
279 }
280 let mut out = args.clone();
281 let mut repaired = false;
282 if let Some(Value::String(encoded)) = out.get("ops")
283 && let Ok(Value::Array(ops)) = serde_json::from_str::<Value>(encoded)
284 {
285 out.insert("ops".into(), Value::Array(ops));
286 repaired = true;
287 }
288 if !out.contains_key("ops") {
289 repaired |= repair(&mut out);
290 }
291 if let Some(Value::Array(ops)) = out.get_mut("ops") {
292 for op in ops.iter_mut().filter_map(Value::as_object_mut) {
293 repaired |= repair(op);
294 }
295 }
296 repaired.then_some(out)
297}
298
299fn delegated_op_kind(v: &Value) -> Option<&str> {
302 v.get("op")
303 .and_then(Value::as_str)
304 .filter(|k| matches!(*k, "replace_unique" | "replace_symbol"))
305}
306
307fn is_unbatchable_op(v: &Value) -> Option<&str> {
315 v.get("op")
316 .and_then(Value::as_str)
317 .filter(|k| matches!(*k, "replace_all" | "create"))
318}
319
320fn handle_mixed_batch(
321 args: &Map<String, Value>,
322 arr: &[Value],
323 ctx: &ToolContext,
324) -> Result<ToolOutput, ErrorData> {
325 for (i, v) in arr.iter().enumerate() {
327 if !v.is_object() {
328 return Err(ErrorData::invalid_params(
329 format!("ops[{i}] must be an object"),
330 None,
331 ));
332 }
333 if let Some(kind) = is_unbatchable_op(v) {
334 return Err(ErrorData::invalid_params(
335 format!(
336 "ops[{i}]: {kind} cannot be batched in ops[] \
337 — send it as a separate top-level ctx_patch call"
338 ),
339 None,
340 ));
341 }
342 }
343
344 let top_path = get_str(args, "path");
347 let mut targets = std::collections::BTreeSet::new();
348 let mut unknown_target = false;
349 for v in arr {
350 match v
351 .get("path")
352 .and_then(Value::as_str)
353 .or(top_path.as_deref())
354 {
355 Some(p) => {
356 targets.insert(p);
357 }
358 None => unknown_target = true,
359 }
360 }
361 let label_parts = unknown_target || targets.len() > 1;
362
363 let mut texts: Vec<String> = Vec::new();
364 let mut run: Vec<Value> = Vec::new();
365 for (i, v) in arr.iter().enumerate() {
366 let obj = v.as_object().ok_or_else(|| {
367 ErrorData::invalid_params(format!("ops[{i}] must be an object"), None)
368 })?;
369 let Some(kind) = delegated_op_kind(v) else {
370 run.push(v.clone());
371 continue;
372 };
373 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
374
375 let (sub_args, sub_ctx) = delegated_op_call(args, obj, ctx, i, kind)
376 .map_err(|error| with_prior_edits(error, &texts))?;
377 let out = if kind == "replace_unique" {
378 delegate_replace_unique(&sub_args, &sub_ctx)
379 } else {
380 delegate_replace_symbol(&sub_args, &sub_ctx)
381 }
382 .map_err(|mut e| {
383 e.message = format!("ops[{i}] ({kind}): {}", e.message).into();
384 with_prior_edits(e, &texts)
385 })?;
386 if label_parts {
387 let label = sub_ctx.resolved_path("path").map_or_else(
388 || kind.to_string(),
389 |resolved| receipt_label(resolved, &ctx.project_root),
390 );
391 texts.push(format!("[{label}]\n{}", out.text));
392 } else {
393 texts.push(out.text);
394 }
395 }
396 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
397
398 Ok(ToolOutput::simple(texts.join("\n\n")))
399}
400
401fn receipt_label(resolved: &str, project_root: &str) -> String {
407 let display = lean_ctx::core::protocol::display_path(resolved);
408 let canonical_root = std::fs::canonicalize(project_root).ok().map(|p| {
409 lean_ctx::core::pathutil::strip_verbatim(p)
410 .to_string_lossy()
411 .into_owned()
412 });
413 for root in std::iter::once(project_root.to_string()).chain(canonical_root) {
414 let root = lean_ctx::core::protocol::display_path(&root);
415 let root = root.trim_end_matches('/');
416 let (Some(head), Some(rest)) = (display.get(..root.len()), display.get(root.len()..))
417 else {
418 continue;
419 };
420 if root.is_empty() || rest.len() < 2 {
421 continue;
422 }
423 let same_root = if cfg!(windows) {
424 head.eq_ignore_ascii_case(root)
425 } else {
426 head == root
427 };
428 if same_root && let Some(rel) = rest.strip_prefix('/') {
429 return rel.to_string();
430 }
431 }
432 display
433}
434
435fn flush_anchored_run(
437 args: &Map<String, Value>,
438 ctx: &ToolContext,
439 run: &mut Vec<Value>,
440 texts: &mut Vec<String>,
441) -> Result<(), ErrorData> {
442 if run.is_empty() {
443 return Ok(());
444 }
445 let mut sub = args.clone();
446 sub.insert("ops".into(), Value::Array(std::mem::take(run)));
447 texts.push(
448 handle_anchored(&sub, ctx)
449 .map_err(|error| with_prior_edits(error, texts))?
450 .text,
451 );
452 Ok(())
453}
454
455fn with_prior_edits(mut error: ErrorData, texts: &[String]) -> ErrorData {
458 if !texts.is_empty() {
459 error.code = crate::server::tool_trait::TOOL_EXECUTION_ERROR;
460 error.message = format!(
461 "{}\nEarlier ops in this batch were already applied:\n{}",
462 error.message,
463 texts.join("\n\n")
464 )
465 .into();
466 }
467 error
468}
469
470fn delegated_op_call(
475 args: &Map<String, Value>,
476 op: &Map<String, Value>,
477 ctx: &ToolContext,
478 i: usize,
479 kind: &str,
480) -> Result<(Map<String, Value>, ToolContext), ErrorData> {
481 let mut sub = op.clone();
482 for key in ["path", "dry_run"] {
483 if !sub.contains_key(key)
484 && let Some(v) = args.get(key)
485 {
486 sub.insert(key.to_string(), v.clone());
487 }
488 }
489
490 let mut sub_ctx = ctx.clone();
491 sub_ctx.resolved_paths.remove("path");
492 sub_ctx.path_errors.remove("path");
493 match get_str(&sub, "path") {
494 Some(raw) => {
495 let resolved = sub_ctx
496 .resolve_path_sync(&raw)
497 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: path: {e}"), None))?;
498 sub_ctx
499 .ensure_writable(&resolved)
500 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: {e}"), None))?;
501 sub_ctx.resolved_paths.insert("path".to_string(), resolved);
502 }
503 None if kind == "replace_unique" => {
506 return Err(ErrorData::invalid_params(
507 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)"),
508 None,
509 ));
510 }
511 None => {}
512 }
513 Ok((sub, sub_ctx))
514}
515
516fn delegate_replace_unique(
520 args: &Map<String, Value>,
521 ctx: &ToolContext,
522) -> Result<ToolOutput, ErrorData> {
523 let edit_args =
524 build_unique_edit_args(args).map_err(|message| ErrorData::invalid_params(message, None))?;
525
526 if get_str(&edit_args, "old_string").is_some_and(|old| old.is_empty()) {
531 let path = require_resolved_path(ctx, args, "path")?;
532 if let Some(message) = empty_old_text_refusal(std::path::Path::new(&path)) {
533 return Err(ErrorData::invalid_params(message, None));
534 }
535 }
536
537 if get_bool(args, "dry_run").unwrap_or(false) {
538 let old_text = get_str(args, "old_text")
539 .or_else(|| get_str(args, "old_string"))
540 .unwrap_or_default();
541 let new_text = get_str(args, "new_text")
542 .or_else(|| get_str(args, "new_string"))
543 .unwrap_or_default();
544 let path = get_str(args, "path").unwrap_or_default();
545 return Ok(ToolOutput::simple(format!(
546 "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
547 )));
548 }
549
550 crate::tools::registered::ctx_edit::handle_edit(&edit_args, ctx, host_draws_diffs(ctx))
551}
552
553fn empty_old_text_refusal(path: &std::path::Path) -> Option<String> {
557 let shown = path.display();
558 match std::fs::symlink_metadata(path) {
559 Ok(meta) if meta.file_type().is_file() && meta.len() == 0 => None,
560 Ok(meta) if meta.file_type().is_file() => Some(format!(
561 "replace_unique with empty old_text only fills an existing empty (0-byte) file; \
562 {shown} has content. Quote unique text from it as old_text, or use anchored ops \
563 (ctx_read mode=\"anchored\" → insert_after/set_line/replace_lines)"
564 )),
565 Ok(_) => Some(format!(
566 "replace_unique with empty old_text only fills an existing empty regular file; \
567 {shown} is not one (symlink or directory)"
568 )),
569 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(format!(
570 "replace_unique with empty old_text only fills an existing empty file; \
571 {shown} does not exist — use op=create to write a new file"
572 )),
573 Err(error) => Some(format!("cannot inspect {shown}: {error}")),
574 }
575}
576
577fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
578 let old_text = get_str(args, "old_text")
581 .or_else(|| get_str(args, "old_string"))
582 .ok_or_else(|| {
583 "replace_unique requires old_text (old_string also accepted); an empty \
584 old_text fills an existing empty file"
585 .to_string()
586 })?;
587 let new_text = get_str(args, "new_text")
588 .or_else(|| get_str(args, "new_string"))
589 .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
590
591 let mut edit_args = args.clone();
592 edit_args.insert("old_string".into(), Value::String(old_text));
593 edit_args.insert("new_string".into(), Value::String(new_text));
594 edit_args.insert("replace_all".into(), Value::Bool(false));
595 edit_args.remove("old_text");
596 edit_args.remove("op");
597 edit_args.remove("create");
600 Ok(edit_args)
601}
602
603fn validate_cross_file_options(
607 args: &Map<String, Value>,
608 file_count: usize,
609) -> Result<(), ErrorData> {
610 if file_count <= 1 {
611 return Ok(());
612 }
613 for key in ["expected_blake3", "expected_md5", "backup_path"] {
614 if args.contains_key(key) {
615 return Err(ErrorData::invalid_params(
616 format!("cross-file ctx_patch batches do not support top-level '{key}'"),
617 None,
618 ));
619 }
620 }
621 Ok(())
622}
623
624fn plan_groups(
629 args: &Map<String, Value>,
630 ctx: &ToolContext,
631) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
632 let Some(ops_val) = args.get("ops") else {
633 let path = require_resolved_path(ctx, args, "path")?;
634 let ops = crate::tools::ctx_patch::parse_ops(args)
635 .map_err(|e| ErrorData::invalid_params(e, None))?;
636 return Ok(vec![(path, ops)]);
637 };
638
639 let arr = ops_val
640 .as_array()
641 .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
642 let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
643 .map_err(|e| ErrorData::invalid_params(e, None))?;
644
645 let mut groups = Vec::with_capacity(grouped.len());
646 for (raw_path, op_objs) in grouped {
647 let resolved = ctx
648 .resolve_path_sync(&raw_path)
649 .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
650 ctx.ensure_writable(&resolved)
651 .map_err(|e| ErrorData::invalid_params(e, None))?;
652 let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
653 let ops = crate::tools::ctx_patch::parse_ops(&sub)
654 .map_err(|e| ErrorData::invalid_params(e, None))?;
655 groups.push((resolved, ops));
656 }
657 Ok(groups)
658}
659
660fn group_ops_by_path(
664 ops: &[Value],
665 top_path: Option<&str>,
666) -> Result<Vec<(String, Vec<Value>)>, String> {
667 if ops.is_empty() {
668 return Err("ops[] is empty — provide at least one edit".to_string());
669 }
670 let mut order: Vec<String> = Vec::new();
671 let mut by_path: std::collections::HashMap<String, Vec<Value>> =
672 std::collections::HashMap::new();
673 for (i, op) in ops.iter().enumerate() {
674 let obj = op
675 .as_object()
676 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
677 let raw = obj
678 .get("path")
679 .and_then(Value::as_str)
680 .map(str::to_string)
681 .or_else(|| top_path.map(str::to_string))
682 .ok_or_else(|| {
683 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
684 })?;
685 if !by_path.contains_key(&raw) {
686 order.push(raw.clone());
687 }
688 by_path.entry(raw).or_default().push(op.clone());
689 }
690 Ok(order
691 .into_iter()
692 .map(|p| {
693 let ops = by_path.remove(&p).unwrap_or_default();
694 (p, ops)
695 })
696 .collect())
697}
698
699fn apply_one(
704 ctx: &ToolContext,
705 params: &crate::tools::ctx_patch::PatchParams,
706) -> Result<String, ErrorData> {
707 let path = params.path.clone();
708 {
709 let cache_lock = ctx
710 .cache
711 .as_ref()
712 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
713
714 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
719 let _file_guard = {
720 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
721 loop {
722 if let Ok(guard) = file_lock.try_lock() {
723 break guard;
724 }
725 if std::time::Instant::now() >= deadline {
726 return Err(ErrorData::internal_error(
727 format!(
728 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
729 ),
730 None,
731 ));
732 }
733 std::thread::sleep(std::time::Duration::from_millis(20));
734 }
735 };
736
737 let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
738 {
739 Some(cache) => cache
740 .get(&path)
741 .map(|e| e.last_mode.clone())
742 .unwrap_or_default(),
743 None => String::new(),
744 };
745
746 let before = std::fs::read(&path).unwrap_or_default();
748 let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
749 let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
750
751 if written {
752 let after = std::fs::read(&path).unwrap_or_default();
753 super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &before, &after);
754 }
755
756 crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
757
758 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
759 crate::tools::ctx_read::dedup_hook::on_write(&path);
760 match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
761 Some(mut cache) => {
762 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
763 }
764 None => {
765 tracing::warn!(
766 "ctx_patch: cache write-lock timeout applying effect for {path}"
767 );
768 }
769 }
770 }
771
772 if !written {
773 return Err(tool_execution_error(output));
774 }
775
776 if let Some(session_lock) = ctx.session.as_ref() {
777 if let Some(mut session) =
778 crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
779 {
780 session.mark_modified(&path);
781 }
782 }
783
784 Ok(output)
785 }
786}
787
788fn delegate_replace_symbol(
793 args: &Map<String, Value>,
794 ctx: &ToolContext,
795) -> Result<ToolOutput, ErrorData> {
796 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
797 .map_err(|e| ErrorData::invalid_params(e, None))?;
798
799 if get_bool(args, "dry_run").unwrap_or(false) {
800 let name = get_str(args, "name").unwrap_or_default();
801 let path = get_str(args, "path").unwrap_or_default();
802 return Ok(ToolOutput::simple(format!(
803 "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
804 )));
805 }
806
807 let has_path = args.get("path").and_then(Value::as_str).is_some();
811 let abs_path = if has_path {
812 require_resolved_path(ctx, args, "path")?
813 } else {
814 String::new()
815 };
816
817 let args_value = Value::Object(refactor_args);
818 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
819 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
820 if !changed {
821 return Err(tool_execution_error(result));
822 }
823
824 Ok(ToolOutput {
825 text: result,
826 original_tokens: 0,
827 saved_tokens: 0,
828 mode: Some("replace_symbol".to_string()),
829 path: get_str(args, "path"),
830 changed,
831 shell_outcome: None,
832 content_blocks: None,
833 })
834}
835
836fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
848 let find = aliased(args, "find", &["old_string", "old_text"])?
849 .filter(|s| !s.is_empty())
850 .ok_or("replace_all requires non-empty 'find' (old_string also accepted)")?;
851 let replace = aliased(args, "replace", &["new_string", "new_text"])?.ok_or(
852 "replace_all requires 'replace' (the replacement text, new_string also accepted); \
853 pass replace=\"\" explicitly to delete every match",
854 )?;
855 Ok((find, replace))
856}
857
858fn aliased(
861 args: &Map<String, Value>,
862 key: &str,
863 aliases: &[&str],
864) -> Result<Option<String>, String> {
865 let mut found: Option<(&str, &str)> = None;
866 for name in std::iter::once(key).chain(aliases.iter().copied()) {
867 let Some(value) = args.get(name).and_then(Value::as_str) else {
868 continue;
869 };
870 match found {
871 Some((first, seen)) if seen != value => {
872 return Err(format!(
873 "'{first}' and '{name}' name the same field with different values — pass only one"
874 ));
875 }
876 Some(_) => {}
877 None => found = Some((name, value)),
878 }
879 }
880 Ok(found.map(|(_, value)| value.to_string()))
881}
882
883fn replace_all_plan(
900 path: &str,
901 root: &str,
902 find: &str,
903) -> Result<(crate::tools::edit_io::FilePreimage, usize), String> {
904 use crate::tools::admitted_sources::SourceRead;
905 let cap = lean_ctx::core::limits::max_read_bytes();
906 let original = crate::tools::edit_io::read_preimage(std::path::Path::new(path), cap, false)?;
907 let mut remaining = cap;
908 let admitted =
909 match crate::tools::admitted_sources::read(path, root, "ctx_patch", &mut remaining) {
910 SourceRead::Admitted(read) => read.content,
911 SourceRead::Withheld(reason) | SourceRead::Unavailable(reason) => {
912 return Err(format!("cannot read {path}: {reason}"));
913 }
914 };
915 let count = admitted.matches(find).count();
916 if count != original.text.matches(find).count() {
917 return Err(format!(
918 "replace_all refused for {path}: the pattern overlaps content the context gateway \
919 masks; nothing was changed"
920 ));
921 }
922 Ok((original, count))
923}
924
925fn replace_all_write(
929 path: &str,
930 pre: &crate::tools::edit_io::FilePreimage,
931 new_content: &str,
932) -> Result<(), String> {
933 let path = std::path::Path::new(path);
934 crate::tools::claims_gate::before_write(path, Some(&pre.text), new_content)?;
935 crate::tools::edit_io::ensure_preimage_still_matches(
936 path,
937 &pre.fp,
938 lean_ctx::core::limits::max_read_bytes(),
939 )?;
940 crate::tools::edit_io::write_atomic_bytes_with_permissions(
941 path,
942 new_content.as_bytes(),
943 Some(&pre.permissions),
944 )
945}
946
947fn handle_replace_all(
949 args: &Map<String, Value>,
950 ctx: &ToolContext,
951) -> Result<ToolOutput, ErrorData> {
952 let path = require_resolved_path(ctx, args, "path")?;
953 let (find, replace) =
954 resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
955 let dry_run = get_bool(args, "dry_run").unwrap_or(false);
956
957 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
960 let _guard = file_lock
961 .lock()
962 .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
963
964 let root = crate::tools::admitted_sources::root_in_context(&path, ctx);
965 let (pre, count) = replace_all_plan(&path, &root, &find).map_err(tool_execution_error)?;
966 if count == 0 {
967 return Ok(ToolOutput::simple(format!(
968 "No matches for {find:?} in {path}"
969 )));
970 }
971
972 if dry_run {
973 return Ok(ToolOutput::simple(format!(
974 "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
975 )));
976 }
977
978 let new_content = pre.text.replace(find.as_str(), &replace);
979 replace_all_write(&path, &pre, &new_content).map_err(tool_execution_error)?;
980 super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &pre.bytes, new_content.as_bytes());
981
982 crate::tools::ctx_read::dedup_hook::on_write(&path);
983 if let Some(cache) = ctx.cache.as_ref() {
984 if let Some(mut c) =
985 crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
986 {
987 c.invalidate(&path);
988 }
989 }
990 if let Some(session_lock) = ctx.session.as_ref() {
991 if let Some(mut session) =
992 crate::server::bounded_lock::write(session_lock, "ctx_patch replace_all session")
993 {
994 session.mark_modified(&path);
995 }
996 }
997
998 Ok(ToolOutput::simple(format!(
999 "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
1000 )))
1001}
1002
1003#[cfg(test)]
1004mod replace_all_tests {
1005 use super::*;
1006 use serde_json::json;
1007
1008 fn obj(v: Value) -> Map<String, Value> {
1009 match v {
1010 Value::Object(m) => m,
1011 _ => panic!("expected object"),
1012 }
1013 }
1014
1015 #[test]
1016 fn resolves_find_and_replace() {
1017 let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
1018 assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
1019 }
1020
1021 #[test]
1022 fn explicit_empty_replace_is_a_deletion() {
1023 let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
1024 assert_eq!(r, "");
1025 }
1026
1027 #[test]
1028 fn missing_replace_is_rejected_not_silent_delete() {
1029 let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
1030 assert!(err.contains("requires 'replace'"), "got: {err}");
1031 }
1032
1033 #[test]
1034 fn other_ops_names_are_aliases() {
1035 for (old, new) in [("old_string", "new_string"), ("old_text", "new_text")] {
1036 let (f, r) = resolve_find_replace(&obj(json!({old: "a", new: "b"}))).unwrap();
1037 assert_eq!((f.as_str(), r.as_str()), ("a", "b"), "{old}/{new}");
1038 }
1039 }
1040
1041 #[test]
1042 fn alias_never_turns_a_missing_replacement_into_a_delete() {
1043 let err = resolve_find_replace(&obj(json!({"old_string": "a"}))).unwrap_err();
1044 assert!(err.contains("requires 'replace'"), "got: {err}");
1045 }
1046
1047 #[test]
1048 fn conflicting_spellings_are_rejected() {
1049 let err = resolve_find_replace(&obj(
1050 json!({"find": "a", "old_string": "x", "replace": "b"}),
1051 ))
1052 .unwrap_err();
1053 assert!(
1054 err.contains("find") && err.contains("old_string"),
1055 "got: {err}"
1056 );
1057 let same = resolve_find_replace(&obj(
1058 json!({"find": "a", "old_string": "a", "replace": "b"}),
1059 ));
1060 assert!(same.is_ok(), "identical spellings agree");
1061 }
1062
1063 #[test]
1064 fn empty_find_is_rejected() {
1065 let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
1066 assert!(err.contains("find"), "got: {err}");
1067 }
1068
1069 #[test]
1075 fn the_reported_count_comes_from_the_admitted_text_and_the_original_is_what_is_written() {
1076 use lean_ctx::core::policy::runtime::TestPolicyOverride;
1077 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1078 let policy = lean_ctx::core::policy::load(
1079 "name = \"ctx-patch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
1080 [redaction]\ncustomer = \"CUS-[0-9]{4}\"\n\
1081 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
1082 )
1083 .unwrap();
1084 let project = tempfile::tempdir().unwrap();
1085 let root = project.path().to_string_lossy().into_owned();
1086 let file = |name: &str, content: &str| {
1087 let path = project.path().join(name);
1088 std::fs::write(&path, content).unwrap();
1089 path.to_string_lossy().into_owned()
1090 };
1091 let original = "let owner = \"CUS-1234\";\nlet mode = old_mode();\nold_mode();\n";
1092 let orders = file("orders.rs", original);
1093 let plan = file("plan.rs", "CONFIDENTIAL\nold_mode();\n");
1094 let planned = |path: &str, find: &str| {
1095 replace_all_plan(path, &root, find).map(|(pre, count)| (pre.text, count))
1096 };
1097 {
1098 let _community = TestPolicyOverride::set(None);
1100 assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1101 }
1102 let _policy = TestPolicyOverride::set(Some(policy));
1103 assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1105 let refused = replace_all_plan(&orders, &root, "CUS-1234").unwrap_err();
1107 assert!(refused.contains("refused"), "{refused}");
1108 assert!(!refused.contains("1 occurrence"), "{refused}");
1109 let withheld = replace_all_plan(&plan, &root, "old_mode").unwrap_err();
1111 assert!(withheld.contains("withheld"), "{withheld}");
1112 assert!(replace_all_plan(&orders, "", "old_mode").is_err());
1114 }
1115
1116 #[test]
1120 fn replace_all_uses_the_edit_boundary() {
1121 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1122 let project = tempfile::tempdir().unwrap();
1123 let root = project.path().to_string_lossy().into_owned();
1124 let target = project.path().join("a.rs");
1125 std::fs::write(&target, "old();\nold();\n").unwrap();
1126 let target_str = target.to_string_lossy().into_owned();
1127
1128 #[cfg(unix)]
1129 {
1130 let link = project.path().join("link.rs");
1131 std::os::unix::fs::symlink(&target, &link).unwrap();
1132 let err = replace_all_plan(&link.to_string_lossy(), &root, "old").unwrap_err();
1133 assert!(err.contains("symlink"), "{err}");
1134 }
1135
1136 let (pre, count) = replace_all_plan(&target_str, &root, "old").unwrap();
1137 assert_eq!(count, 2);
1138 std::fs::write(&target, "old();\nnewer();\n").unwrap();
1140 let new_content = pre.text.replace("old", "new");
1141 let err = replace_all_write(&target_str, &pre, &new_content).unwrap_err();
1142 assert!(err.contains("TOCTOU"), "{err}");
1143 assert_eq!(
1144 std::fs::read_to_string(&target).unwrap(),
1145 "old();\nnewer();\n"
1146 );
1147
1148 let (pre, _) = replace_all_plan(&target_str, &root, "old").unwrap();
1150 let new_content = pre.text.replace("old", "new");
1151 replace_all_write(&target_str, &pre, &new_content).unwrap();
1152 assert_eq!(
1153 std::fs::read_to_string(&target).unwrap(),
1154 "new();\nnewer();\n"
1155 );
1156 }
1157}
1158
1159#[cfg(test)]
1160mod batch_grouping_tests {
1161 use super::*;
1162 use serde_json::json;
1163
1164 #[test]
1167 fn groups_ops_across_files_preserving_order() {
1168 let ops = vec![
1169 json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
1170 json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
1171 json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
1172 ];
1173 let g = group_ops_by_path(&ops, None).unwrap();
1174 assert_eq!(g.len(), 2);
1175 assert_eq!(g[0].0, "a.go");
1176 assert_eq!(g[0].1.len(), 2);
1177 assert_eq!(g[1].0, "b.go");
1178 assert_eq!(g[1].1.len(), 1);
1179 }
1180
1181 #[test]
1182 fn ops_without_path_fall_back_to_top_level() {
1183 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1184 let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
1185 assert_eq!(g.len(), 1);
1186 assert_eq!(g[0].0, "top.go");
1187 }
1188
1189 #[test]
1190 fn op_without_path_and_no_top_level_is_rejected() {
1191 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1192 let err = group_ops_by_path(&ops, None).unwrap_err();
1193 assert!(err.contains("path"), "got: {err}");
1194 }
1195
1196 #[test]
1197 fn empty_ops_rejected() {
1198 let err = group_ops_by_path(&[], None).unwrap_err();
1199 assert!(err.contains("empty"), "got: {err}");
1200 }
1201
1202 #[test]
1203 fn cross_file_rejects_single_value_preimage_and_backup_options() {
1204 for key in ["expected_blake3", "expected_md5", "backup_path"] {
1205 let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
1206 assert!(validate_cross_file_options(&args, 2).is_err());
1207 assert!(validate_cross_file_options(&args, 1).is_ok());
1208 }
1209 }
1210
1211 #[test]
1212 fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
1213 let args = Map::from_iter([
1214 ("path".into(), json!("a.rs")),
1215 ("op".into(), json!("replace_unique")),
1216 ("old_text".into(), json!("old")),
1217 ("new_text".into(), json!("new")),
1218 ]);
1219 let mapped = build_unique_edit_args(&args).expect("valid mapping");
1220 assert_eq!(mapped.get("old_string"), Some(&json!("old")));
1221 assert_eq!(mapped.get("new_string"), Some(&json!("new")));
1222 assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
1223 assert!(!mapped.contains_key("op"));
1224 assert!(!mapped.contains_key("old_text"));
1225 }
1226
1227 #[test]
1228 fn replace_unique_requires_explicit_old_and_new_text() {
1229 assert!(build_unique_edit_args(&Map::new()).is_err());
1230 let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
1231 assert!(build_unique_edit_args(&only_old).is_err());
1232 }
1233
1234 #[test]
1235 fn dry_run_replace_unique_does_not_apply() {
1236 let args = Map::from_iter([
1237 ("path".into(), json!("a.rs")),
1238 ("op".into(), json!("replace_unique")),
1239 ("old_text".into(), json!("old")),
1240 ("new_text".into(), json!("new")),
1241 ("dry_run".into(), json!(true)),
1242 ]);
1243 let edit_args = build_unique_edit_args(&args).expect("validation passes");
1244 assert!(
1245 edit_args.contains_key("old_string"),
1246 "args mapped correctly"
1247 );
1248 assert!(
1249 args.get("dry_run").and_then(Value::as_bool) == Some(true),
1250 "dry_run flag preserved in original args"
1251 );
1252 }
1253
1254 #[test]
1255 fn text_replacement_without_op_is_inferred_as_replace_unique() {
1256 let top = Map::from_iter([
1257 ("path".into(), json!("a.rs")),
1258 ("old_string".into(), json!("old")),
1259 ("new_string".into(), json!("new")),
1260 ]);
1261 let inferred = infer_replace_unique(&top).expect("op inferred");
1262 assert_eq!(inferred.get("op"), Some(&json!("replace_unique")));
1263
1264 let batch = Map::from_iter([(
1265 "ops".into(),
1266 json!([
1267 {"path": "a.rs", "old_text": "a", "new_text": "b"},
1268 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "x"}
1269 ]),
1270 )]);
1271 let inferred = infer_replace_unique(&batch).expect("op inferred in ops[]");
1272 assert_eq!(inferred["ops"][0]["op"], json!("replace_unique"));
1273 assert_eq!(inferred["ops"][1]["op"], json!("set_line"));
1274
1275 for unchanged in [
1277 json!({"op": "set_line", "old_text": "a", "new_text": "b"}),
1278 json!({"old_text": "", "new_text": "b"}),
1279 json!({"old_text": "a"}),
1280 ] {
1281 assert!(infer_replace_unique(unchanged.as_object().unwrap()).is_none());
1282 }
1283
1284 let encoded = json!({"ops": "[{\"op\":\"replace_unique\",\"path\":\"a.rs\",\"old_text\":\"a\",\"new_text\":\"b\"}]"});
1286 let repaired = infer_replace_unique(encoded.as_object().unwrap()).expect("ops decoded");
1287 assert_eq!(repaired["ops"][0]["old_text"], json!("a"));
1288 let create = json!({"op": "create", "path": "n.py", "new_string": "x = 1\n"});
1289 let repaired = infer_replace_unique(create.as_object().unwrap()).expect("create aliased");
1290 assert_eq!(repaired["new_text"], json!("x = 1\n"));
1291 let find_replace =
1292 json!({"op": "replace_unique", "path": "a.rs", "find": "a", "replace": "b"});
1293 let repaired = infer_replace_unique(find_replace.as_object().unwrap()).expect("aliased");
1294 assert_eq!(
1295 (&repaired["old_text"], &repaired["new_text"]),
1296 (&json!("a"), &json!("b"))
1297 );
1298 let replace_all = json!({"op": "replace_all", "path": "a.rs", "find": "a", "replace": "b"});
1300 assert!(infer_replace_unique(replace_all.as_object().unwrap()).is_none());
1301 }
1302
1303 fn empty_old_text_patch(
1306 root: &std::path::Path,
1307 path: &std::path::Path,
1308 ) -> Result<String, ErrorData> {
1309 let resolved = path.to_string_lossy().into_owned();
1310 let ctx = ToolContext {
1311 project_root: root.to_string_lossy().into_owned(),
1312 resolved_paths: std::collections::HashMap::from([("path".into(), resolved.clone())]),
1313 cache: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
1314 lean_ctx::core::cache::SessionCache::new(),
1315 ))),
1316 ..ToolContext::default()
1317 };
1318 let args = json!({
1319 "op": "replace_unique", "path": resolved, "old_text": "", "new_text": "filled\n"
1320 });
1321 tokio::task::block_in_place(|| CtxPatchTool.handle(args.as_object().unwrap(), &ctx))
1322 .map(|out| out.text)
1323 }
1324
1325 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1326 async fn replace_unique_with_empty_old_text_fills_an_empty_file() {
1327 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1329 let dir = tempfile::tempdir().unwrap();
1330 let file = dir.path().join("empty.txt");
1331 std::fs::write(&file, "").unwrap();
1332 let text = empty_old_text_patch(dir.path(), &file).expect("empty file is filled");
1333 assert!(text.contains("created empty.txt"), "{text}");
1334 assert_eq!(std::fs::read_to_string(&file).unwrap(), "filled\n");
1335 }
1336
1337 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1338 async fn replace_unique_with_empty_old_text_refuses_other_targets() {
1339 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1340 let dir = tempfile::tempdir().unwrap();
1341
1342 let full = dir.path().join("full.txt");
1343 std::fs::write(&full, "keep\n").unwrap();
1344 let err = empty_old_text_patch(dir.path(), &full).unwrap_err();
1345 assert!(err.message.contains("anchored"), "{}", err.message);
1346 assert_eq!(std::fs::read_to_string(&full).unwrap(), "keep\n");
1347
1348 let missing = dir.path().join("missing.txt");
1349 let err = empty_old_text_patch(dir.path(), &missing).unwrap_err();
1350 assert!(err.message.contains("op=create"), "{}", err.message);
1351 assert!(!missing.exists(), "replace_unique must not create files");
1352
1353 #[cfg(unix)]
1354 {
1355 let target = dir.path().join("target.txt");
1356 std::fs::write(&target, "").unwrap();
1357 let link = dir.path().join("link.txt");
1358 std::os::unix::fs::symlink(&target, &link).unwrap();
1359 let err = empty_old_text_patch(dir.path(), &link).unwrap_err();
1360 assert!(err.message.contains("not one"), "{}", err.message);
1361 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
1362 }
1363 }
1364
1365 #[test]
1366 fn replace_unique_never_forwards_a_whole_file_create() {
1367 let args = Map::from_iter([
1368 ("old_text".into(), json!("old")),
1369 ("new_text".into(), json!("new")),
1370 ("create".into(), json!(true)),
1371 ]);
1372 let mapped = build_unique_edit_args(&args).expect("valid mapping");
1373 assert!(!mapped.contains_key("create"));
1374 }
1375}