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 let inferred = infer_replace_unique(args);
108 let args = inferred.as_ref().unwrap_or(args);
109
110 if crate::tools::ctx_patch::is_replace_symbol(args) {
113 return delegate_replace_symbol(args, ctx);
114 }
115
116 if get_str(args, "op").as_deref() == Some("replace_unique") {
117 return delegate_replace_unique(args, ctx);
118 }
119
120 if get_str(args, "op").as_deref() == Some("replace_all") {
122 return handle_replace_all(args, ctx);
123 }
124
125 if let Some(arr) = args.get("ops").and_then(Value::as_array) {
129 for (i, v) in arr.iter().enumerate() {
131 if let Some(kind) = is_unbatchable_op(v) {
132 return Err(ErrorData::invalid_params(
133 format!(
134 "ops[{i}]: {kind} cannot be batched in ops[] \
135 — send it as a separate top-level ctx_patch call"
136 ),
137 None,
138 ));
139 }
140 }
141 if arr.iter().any(|v| delegated_op_kind(v).is_some()) {
142 return handle_mixed_batch(args, arr, ctx);
143 }
144 }
145
146 handle_anchored(args, ctx)
147 }
148}
149
150fn handle_anchored(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
154 if get_bool(args, "dry_run").unwrap_or(false) {
155 let path = get_str(args, "path").unwrap_or_default();
156 return Ok(ToolOutput::simple(format!(
157 "DRY RUN: ctx_patch would apply anchor-based ops to {path}"
158 )));
159 }
160
161 let expected_blake3 =
164 get_str(args, "expected_blake3").or_else(|| get_str(args, "expected_md5"));
165 let backup = get_bool(args, "backup").unwrap_or(false);
166 let backup_path = get_str(args, "backup_path")
167 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p))
168 .map(|p| lean_ctx::core::workspace_authority::confine(&p, &ctx.project_root, true))
169 .transpose()
170 .map_err(|error| ErrorData::invalid_params(error, None))?;
171 let evidence = get_bool(args, "evidence").unwrap_or(false);
173 let diff_max_lines = get_int(args, "diff_max_lines")
174 .and_then(|v| usize::try_from(v.max(0)).ok())
175 .unwrap_or(200);
176 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
177 let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
178
179 let groups = plan_groups(args, ctx)?;
183 validate_cross_file_options(args, groups.len())?;
184 let output_path = (groups.len() == 1).then(|| groups[0].0.clone());
185 let label_parts = groups.len() > 1;
188 let diff_artifact = host_draws_diffs(ctx);
189
190 let mut texts = Vec::with_capacity(groups.len());
191 for (path, ops) in groups {
192 let patch_params = crate::tools::ctx_patch::PatchParams {
193 path: path.clone(),
194 ops,
195 expected_blake3: expected_blake3.clone(),
196 backup,
197 backup_path: backup_path.clone(),
198 evidence,
199 diff_artifact,
200 diff_max_lines,
201 allow_lossy_utf8,
202 validate_syntax,
203 };
204 let output =
205 apply_one(ctx, &patch_params).map_err(|error| with_prior_edits(error, &texts))?;
206 texts.push(if label_parts {
207 format!("[{}]\n{output}", receipt_label(&path, &ctx.project_root))
208 } else {
209 output
210 });
211 }
212
213 Ok(ToolOutput {
214 text: texts.join("\n\n"),
215 original_tokens: 0,
216 saved_tokens: 0,
217 mode: None,
218 path: output_path,
219 changed: false,
220 shell_outcome: None,
221 content_blocks: None,
222 })
223}
224
225fn infer_replace_unique(args: &Map<String, Value>) -> Option<Map<String, Value>> {
235 fn is_text_replacement(obj: &Map<String, Value>) -> bool {
236 let text = |keys: [&str; 2]| keys.iter().find_map(|key| obj.get(*key)?.as_str());
237 !obj.contains_key("op")
238 && text(["old_text", "old_string"]).is_some_and(|old| !old.is_empty())
239 && text(["new_text", "new_string"]).is_some()
240 }
241 fn alias(obj: &mut Map<String, Value>, key: &str, aliases: &[&str]) -> bool {
243 if obj.contains_key(key) {
244 return false;
245 }
246 let Some(value) = aliases
247 .iter()
248 .find_map(|alias| obj.get(*alias).filter(|v| v.is_string()).cloned())
249 else {
250 return false;
251 };
252 obj.insert(key.to_string(), value);
253 true
254 }
255 fn repair(obj: &mut Map<String, Value>) -> bool {
256 let mut changed = false;
257 if is_text_replacement(obj) {
258 obj.insert("op".into(), json!("replace_unique"));
259 changed = true;
260 }
261 match obj.get("op").and_then(Value::as_str) {
262 Some("replace_unique") => {
263 changed |= alias(obj, "old_text", &["old_string", "find"]);
264 changed |= alias(obj, "new_text", &["new_string", "replace"]);
265 }
266 Some("create") => changed |= alias(obj, "new_text", &["new_string", "content"]),
267 Some("set_line" | "replace_lines" | "insert_after") => {
268 changed |= alias(obj, "new_text", &["new_string"]);
269 }
270 _ => {}
271 }
272 changed
273 }
274 let mut out = args.clone();
275 let mut repaired = false;
276 if let Some(Value::String(encoded)) = out.get("ops")
277 && let Ok(Value::Array(ops)) = serde_json::from_str::<Value>(encoded)
278 {
279 out.insert("ops".into(), Value::Array(ops));
280 repaired = true;
281 }
282 if !out.contains_key("ops") {
283 repaired |= repair(&mut out);
284 }
285 if let Some(Value::Array(ops)) = out.get_mut("ops") {
286 for op in ops.iter_mut().filter_map(Value::as_object_mut) {
287 repaired |= repair(op);
288 }
289 }
290 repaired.then_some(out)
291}
292
293fn delegated_op_kind(v: &Value) -> Option<&str> {
296 v.get("op")
297 .and_then(Value::as_str)
298 .filter(|k| matches!(*k, "replace_unique" | "replace_symbol"))
299}
300
301fn is_unbatchable_op(v: &Value) -> Option<&str> {
309 v.get("op")
310 .and_then(Value::as_str)
311 .filter(|k| matches!(*k, "replace_all" | "create"))
312}
313
314fn handle_mixed_batch(
315 args: &Map<String, Value>,
316 arr: &[Value],
317 ctx: &ToolContext,
318) -> Result<ToolOutput, ErrorData> {
319 for (i, v) in arr.iter().enumerate() {
321 if !v.is_object() {
322 return Err(ErrorData::invalid_params(
323 format!("ops[{i}] must be an object"),
324 None,
325 ));
326 }
327 if let Some(kind) = is_unbatchable_op(v) {
328 return Err(ErrorData::invalid_params(
329 format!(
330 "ops[{i}]: {kind} cannot be batched in ops[] \
331 — send it as a separate top-level ctx_patch call"
332 ),
333 None,
334 ));
335 }
336 }
337
338 let top_path = get_str(args, "path");
341 let mut targets = std::collections::BTreeSet::new();
342 let mut unknown_target = false;
343 for v in arr {
344 match v
345 .get("path")
346 .and_then(Value::as_str)
347 .or(top_path.as_deref())
348 {
349 Some(p) => {
350 targets.insert(p);
351 }
352 None => unknown_target = true,
353 }
354 }
355 let label_parts = unknown_target || targets.len() > 1;
356
357 let mut texts: Vec<String> = Vec::new();
358 let mut run: Vec<Value> = Vec::new();
359 for (i, v) in arr.iter().enumerate() {
360 let obj = v.as_object().ok_or_else(|| {
361 ErrorData::invalid_params(format!("ops[{i}] must be an object"), None)
362 })?;
363 let Some(kind) = delegated_op_kind(v) else {
364 run.push(v.clone());
365 continue;
366 };
367 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
368
369 let (sub_args, sub_ctx) = delegated_op_call(args, obj, ctx, i, kind)
370 .map_err(|error| with_prior_edits(error, &texts))?;
371 let out = if kind == "replace_unique" {
372 delegate_replace_unique(&sub_args, &sub_ctx)
373 } else {
374 delegate_replace_symbol(&sub_args, &sub_ctx)
375 }
376 .map_err(|mut e| {
377 e.message = format!("ops[{i}] ({kind}): {}", e.message).into();
378 with_prior_edits(e, &texts)
379 })?;
380 if label_parts {
381 let label = sub_ctx.resolved_path("path").map_or_else(
382 || kind.to_string(),
383 |resolved| receipt_label(resolved, &ctx.project_root),
384 );
385 texts.push(format!("[{label}]\n{}", out.text));
386 } else {
387 texts.push(out.text);
388 }
389 }
390 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
391
392 Ok(ToolOutput::simple(texts.join("\n\n")))
393}
394
395fn receipt_label(resolved: &str, project_root: &str) -> String {
401 let display = lean_ctx::core::protocol::display_path(resolved);
402 let canonical_root = std::fs::canonicalize(project_root).ok().map(|p| {
403 lean_ctx::core::pathutil::strip_verbatim(p)
404 .to_string_lossy()
405 .into_owned()
406 });
407 for root in std::iter::once(project_root.to_string()).chain(canonical_root) {
408 let root = lean_ctx::core::protocol::display_path(&root);
409 let root = root.trim_end_matches('/');
410 let (Some(head), Some(rest)) = (display.get(..root.len()), display.get(root.len()..))
411 else {
412 continue;
413 };
414 if root.is_empty() || rest.len() < 2 {
415 continue;
416 }
417 let same_root = if cfg!(windows) {
418 head.eq_ignore_ascii_case(root)
419 } else {
420 head == root
421 };
422 if same_root && let Some(rel) = rest.strip_prefix('/') {
423 return rel.to_string();
424 }
425 }
426 display
427}
428
429fn flush_anchored_run(
431 args: &Map<String, Value>,
432 ctx: &ToolContext,
433 run: &mut Vec<Value>,
434 texts: &mut Vec<String>,
435) -> Result<(), ErrorData> {
436 if run.is_empty() {
437 return Ok(());
438 }
439 let mut sub = args.clone();
440 sub.insert("ops".into(), Value::Array(std::mem::take(run)));
441 texts.push(
442 handle_anchored(&sub, ctx)
443 .map_err(|error| with_prior_edits(error, texts))?
444 .text,
445 );
446 Ok(())
447}
448
449fn with_prior_edits(mut error: ErrorData, texts: &[String]) -> ErrorData {
452 if !texts.is_empty() {
453 error.code = crate::server::tool_trait::TOOL_EXECUTION_ERROR;
454 error.message = format!(
455 "{}\nEarlier ops in this batch were already applied:\n{}",
456 error.message,
457 texts.join("\n\n")
458 )
459 .into();
460 }
461 error
462}
463
464fn delegated_op_call(
469 args: &Map<String, Value>,
470 op: &Map<String, Value>,
471 ctx: &ToolContext,
472 i: usize,
473 kind: &str,
474) -> Result<(Map<String, Value>, ToolContext), ErrorData> {
475 let mut sub = op.clone();
476 for key in ["path", "dry_run"] {
477 if !sub.contains_key(key)
478 && let Some(v) = args.get(key)
479 {
480 sub.insert(key.to_string(), v.clone());
481 }
482 }
483
484 let mut sub_ctx = ctx.clone();
485 sub_ctx.resolved_paths.remove("path");
486 sub_ctx.path_errors.remove("path");
487 match get_str(&sub, "path") {
488 Some(raw) => {
489 let resolved = sub_ctx
490 .resolve_path_sync(&raw)
491 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: path: {e}"), None))?;
492 sub_ctx
493 .ensure_writable(&resolved)
494 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: {e}"), None))?;
495 sub_ctx.resolved_paths.insert("path".to_string(), resolved);
496 }
497 None if kind == "replace_unique" => {
500 return Err(ErrorData::invalid_params(
501 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)"),
502 None,
503 ));
504 }
505 None => {}
506 }
507 Ok((sub, sub_ctx))
508}
509
510fn delegate_replace_unique(
514 args: &Map<String, Value>,
515 ctx: &ToolContext,
516) -> Result<ToolOutput, ErrorData> {
517 let edit_args =
518 build_unique_edit_args(args).map_err(|message| ErrorData::invalid_params(message, None))?;
519
520 if get_str(&edit_args, "old_string").is_some_and(|old| old.is_empty()) {
525 let path = require_resolved_path(ctx, args, "path")?;
526 if let Some(message) = empty_old_text_refusal(std::path::Path::new(&path)) {
527 return Err(ErrorData::invalid_params(message, None));
528 }
529 }
530
531 if get_bool(args, "dry_run").unwrap_or(false) {
532 let old_text = get_str(args, "old_text")
533 .or_else(|| get_str(args, "old_string"))
534 .unwrap_or_default();
535 let new_text = get_str(args, "new_text")
536 .or_else(|| get_str(args, "new_string"))
537 .unwrap_or_default();
538 let path = get_str(args, "path").unwrap_or_default();
539 return Ok(ToolOutput::simple(format!(
540 "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
541 )));
542 }
543
544 crate::tools::registered::ctx_edit::handle_edit(&edit_args, ctx, host_draws_diffs(ctx))
545}
546
547fn empty_old_text_refusal(path: &std::path::Path) -> Option<String> {
551 let shown = path.display();
552 match std::fs::symlink_metadata(path) {
553 Ok(meta) if meta.file_type().is_file() && meta.len() == 0 => None,
554 Ok(meta) if meta.file_type().is_file() => Some(format!(
555 "replace_unique with empty old_text only fills an existing empty (0-byte) file; \
556 {shown} has content. Quote unique text from it as old_text, or use anchored ops \
557 (ctx_read mode=\"anchored\" → insert_after/set_line/replace_lines)"
558 )),
559 Ok(_) => Some(format!(
560 "replace_unique with empty old_text only fills an existing empty regular file; \
561 {shown} is not one (symlink or directory)"
562 )),
563 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(format!(
564 "replace_unique with empty old_text only fills an existing empty file; \
565 {shown} does not exist — use op=create to write a new file"
566 )),
567 Err(error) => Some(format!("cannot inspect {shown}: {error}")),
568 }
569}
570
571fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
572 let old_text = get_str(args, "old_text")
575 .or_else(|| get_str(args, "old_string"))
576 .ok_or_else(|| {
577 "replace_unique requires old_text (old_string also accepted); an empty \
578 old_text fills an existing empty file"
579 .to_string()
580 })?;
581 let new_text = get_str(args, "new_text")
582 .or_else(|| get_str(args, "new_string"))
583 .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
584
585 let mut edit_args = args.clone();
586 edit_args.insert("old_string".into(), Value::String(old_text));
587 edit_args.insert("new_string".into(), Value::String(new_text));
588 edit_args.insert("replace_all".into(), Value::Bool(false));
589 edit_args.remove("old_text");
590 edit_args.remove("op");
591 edit_args.remove("create");
594 Ok(edit_args)
595}
596
597fn validate_cross_file_options(
601 args: &Map<String, Value>,
602 file_count: usize,
603) -> Result<(), ErrorData> {
604 if file_count <= 1 {
605 return Ok(());
606 }
607 for key in ["expected_blake3", "expected_md5", "backup_path"] {
608 if args.contains_key(key) {
609 return Err(ErrorData::invalid_params(
610 format!("cross-file ctx_patch batches do not support top-level '{key}'"),
611 None,
612 ));
613 }
614 }
615 Ok(())
616}
617
618fn plan_groups(
623 args: &Map<String, Value>,
624 ctx: &ToolContext,
625) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
626 let Some(ops_val) = args.get("ops") else {
627 let path = require_resolved_path(ctx, args, "path")?;
628 let ops = crate::tools::ctx_patch::parse_ops(args)
629 .map_err(|e| ErrorData::invalid_params(e, None))?;
630 return Ok(vec![(path, ops)]);
631 };
632
633 let arr = ops_val
634 .as_array()
635 .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
636 let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
637 .map_err(|e| ErrorData::invalid_params(e, None))?;
638
639 let mut groups = Vec::with_capacity(grouped.len());
640 for (raw_path, op_objs) in grouped {
641 let resolved = ctx
642 .resolve_path_sync(&raw_path)
643 .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
644 ctx.ensure_writable(&resolved)
645 .map_err(|e| ErrorData::invalid_params(e, None))?;
646 let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
647 let ops = crate::tools::ctx_patch::parse_ops(&sub)
648 .map_err(|e| ErrorData::invalid_params(e, None))?;
649 groups.push((resolved, ops));
650 }
651 Ok(groups)
652}
653
654fn group_ops_by_path(
658 ops: &[Value],
659 top_path: Option<&str>,
660) -> Result<Vec<(String, Vec<Value>)>, String> {
661 if ops.is_empty() {
662 return Err("ops[] is empty — provide at least one edit".to_string());
663 }
664 let mut order: Vec<String> = Vec::new();
665 let mut by_path: std::collections::HashMap<String, Vec<Value>> =
666 std::collections::HashMap::new();
667 for (i, op) in ops.iter().enumerate() {
668 let obj = op
669 .as_object()
670 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
671 let raw = obj
672 .get("path")
673 .and_then(Value::as_str)
674 .map(str::to_string)
675 .or_else(|| top_path.map(str::to_string))
676 .ok_or_else(|| {
677 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
678 })?;
679 if !by_path.contains_key(&raw) {
680 order.push(raw.clone());
681 }
682 by_path.entry(raw).or_default().push(op.clone());
683 }
684 Ok(order
685 .into_iter()
686 .map(|p| {
687 let ops = by_path.remove(&p).unwrap_or_default();
688 (p, ops)
689 })
690 .collect())
691}
692
693fn apply_one(
698 ctx: &ToolContext,
699 params: &crate::tools::ctx_patch::PatchParams,
700) -> Result<String, ErrorData> {
701 let path = params.path.clone();
702 {
703 let cache_lock = ctx
704 .cache
705 .as_ref()
706 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
707
708 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
713 let _file_guard = {
714 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
715 loop {
716 if let Ok(guard) = file_lock.try_lock() {
717 break guard;
718 }
719 if std::time::Instant::now() >= deadline {
720 return Err(ErrorData::internal_error(
721 format!(
722 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
723 ),
724 None,
725 ));
726 }
727 std::thread::sleep(std::time::Duration::from_millis(20));
728 }
729 };
730
731 let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
732 {
733 Some(cache) => cache
734 .get(&path)
735 .map(|e| e.last_mode.clone())
736 .unwrap_or_default(),
737 None => String::new(),
738 };
739
740 let before = std::fs::read(&path).unwrap_or_default();
742 let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
743 let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
744
745 if written {
746 let after = std::fs::read(&path).unwrap_or_default();
747 super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &before, &after);
748 }
749
750 crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
751
752 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
753 crate::tools::ctx_read::dedup_hook::on_write(&path);
754 match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
755 Some(mut cache) => {
756 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
757 }
758 None => {
759 tracing::warn!(
760 "ctx_patch: cache write-lock timeout applying effect for {path}"
761 );
762 }
763 }
764 }
765
766 if !written {
767 return Err(tool_execution_error(output));
768 }
769
770 if let Some(session_lock) = ctx.session.as_ref() {
771 if let Some(mut session) =
772 crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
773 {
774 session.mark_modified(&path);
775 }
776 }
777
778 Ok(output)
779 }
780}
781
782fn delegate_replace_symbol(
787 args: &Map<String, Value>,
788 ctx: &ToolContext,
789) -> Result<ToolOutput, ErrorData> {
790 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
791 .map_err(|e| ErrorData::invalid_params(e, None))?;
792
793 if get_bool(args, "dry_run").unwrap_or(false) {
794 let name = get_str(args, "name").unwrap_or_default();
795 let path = get_str(args, "path").unwrap_or_default();
796 return Ok(ToolOutput::simple(format!(
797 "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
798 )));
799 }
800
801 let has_path = args.get("path").and_then(Value::as_str).is_some();
805 let abs_path = if has_path {
806 require_resolved_path(ctx, args, "path")?
807 } else {
808 String::new()
809 };
810
811 let args_value = Value::Object(refactor_args);
812 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
813 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
814 if !changed {
815 return Err(tool_execution_error(result));
816 }
817
818 Ok(ToolOutput {
819 text: result,
820 original_tokens: 0,
821 saved_tokens: 0,
822 mode: Some("replace_symbol".to_string()),
823 path: get_str(args, "path"),
824 changed,
825 shell_outcome: None,
826 content_blocks: None,
827 })
828}
829
830fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
842 let find = aliased(args, "find", &["old_string", "old_text"])?
843 .filter(|s| !s.is_empty())
844 .ok_or("replace_all requires non-empty 'find' (old_string also accepted)")?;
845 let replace = aliased(args, "replace", &["new_string", "new_text"])?.ok_or(
846 "replace_all requires 'replace' (the replacement text, new_string also accepted); \
847 pass replace=\"\" explicitly to delete every match",
848 )?;
849 Ok((find, replace))
850}
851
852fn aliased(
855 args: &Map<String, Value>,
856 key: &str,
857 aliases: &[&str],
858) -> Result<Option<String>, String> {
859 let mut found: Option<(&str, &str)> = None;
860 for name in std::iter::once(key).chain(aliases.iter().copied()) {
861 let Some(value) = args.get(name).and_then(Value::as_str) else {
862 continue;
863 };
864 match found {
865 Some((first, seen)) if seen != value => {
866 return Err(format!(
867 "'{first}' and '{name}' name the same field with different values — pass only one"
868 ));
869 }
870 Some(_) => {}
871 None => found = Some((name, value)),
872 }
873 }
874 Ok(found.map(|(_, value)| value.to_string()))
875}
876
877fn replace_all_plan(
894 path: &str,
895 root: &str,
896 find: &str,
897) -> Result<(crate::tools::edit_io::FilePreimage, usize), String> {
898 use crate::tools::admitted_sources::SourceRead;
899 let cap = lean_ctx::core::limits::max_read_bytes();
900 let original = crate::tools::edit_io::read_preimage(std::path::Path::new(path), cap, false)?;
901 let mut remaining = cap;
902 let admitted =
903 match crate::tools::admitted_sources::read(path, root, "ctx_patch", &mut remaining) {
904 SourceRead::Admitted(read) => read.content,
905 SourceRead::Withheld(reason) | SourceRead::Unavailable(reason) => {
906 return Err(format!("cannot read {path}: {reason}"));
907 }
908 };
909 let count = admitted.matches(find).count();
910 if count != original.text.matches(find).count() {
911 return Err(format!(
912 "replace_all refused for {path}: the pattern overlaps content the context gateway \
913 masks; nothing was changed"
914 ));
915 }
916 Ok((original, count))
917}
918
919fn replace_all_write(
923 path: &str,
924 pre: &crate::tools::edit_io::FilePreimage,
925 new_content: &str,
926) -> Result<(), String> {
927 let path = std::path::Path::new(path);
928 crate::tools::edit_io::ensure_preimage_still_matches(
929 path,
930 &pre.fp,
931 lean_ctx::core::limits::max_read_bytes(),
932 )?;
933 crate::tools::edit_io::write_atomic_bytes_with_permissions(
934 path,
935 new_content.as_bytes(),
936 Some(&pre.permissions),
937 )
938}
939
940fn handle_replace_all(
942 args: &Map<String, Value>,
943 ctx: &ToolContext,
944) -> Result<ToolOutput, ErrorData> {
945 let path = require_resolved_path(ctx, args, "path")?;
946 let (find, replace) =
947 resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
948 let dry_run = get_bool(args, "dry_run").unwrap_or(false);
949
950 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
953 let _guard = file_lock
954 .lock()
955 .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
956
957 let root = crate::tools::admitted_sources::root_in_context(&path, ctx);
958 let (pre, count) = replace_all_plan(&path, &root, &find).map_err(tool_execution_error)?;
959 if count == 0 {
960 return Ok(ToolOutput::simple(format!(
961 "No matches for {find:?} in {path}"
962 )));
963 }
964
965 if dry_run {
966 return Ok(ToolOutput::simple(format!(
967 "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
968 )));
969 }
970
971 let new_content = pre.text.replace(find.as_str(), &replace);
972 replace_all_write(&path, &pre, &new_content).map_err(tool_execution_error)?;
973 super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &pre.bytes, new_content.as_bytes());
974
975 crate::tools::ctx_read::dedup_hook::on_write(&path);
976 if let Some(cache) = ctx.cache.as_ref() {
977 if let Some(mut c) =
978 crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
979 {
980 c.invalidate(&path);
981 }
982 }
983 if let Some(session_lock) = ctx.session.as_ref() {
984 if let Some(mut session) =
985 crate::server::bounded_lock::write(session_lock, "ctx_patch replace_all session")
986 {
987 session.mark_modified(&path);
988 }
989 }
990
991 Ok(ToolOutput::simple(format!(
992 "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
993 )))
994}
995
996#[cfg(test)]
997mod replace_all_tests {
998 use super::*;
999 use serde_json::json;
1000
1001 fn obj(v: Value) -> Map<String, Value> {
1002 match v {
1003 Value::Object(m) => m,
1004 _ => panic!("expected object"),
1005 }
1006 }
1007
1008 #[test]
1009 fn resolves_find_and_replace() {
1010 let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
1011 assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
1012 }
1013
1014 #[test]
1015 fn explicit_empty_replace_is_a_deletion() {
1016 let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
1017 assert_eq!(r, "");
1018 }
1019
1020 #[test]
1021 fn missing_replace_is_rejected_not_silent_delete() {
1022 let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
1023 assert!(err.contains("requires 'replace'"), "got: {err}");
1024 }
1025
1026 #[test]
1027 fn other_ops_names_are_aliases() {
1028 for (old, new) in [("old_string", "new_string"), ("old_text", "new_text")] {
1029 let (f, r) = resolve_find_replace(&obj(json!({old: "a", new: "b"}))).unwrap();
1030 assert_eq!((f.as_str(), r.as_str()), ("a", "b"), "{old}/{new}");
1031 }
1032 }
1033
1034 #[test]
1035 fn alias_never_turns_a_missing_replacement_into_a_delete() {
1036 let err = resolve_find_replace(&obj(json!({"old_string": "a"}))).unwrap_err();
1037 assert!(err.contains("requires 'replace'"), "got: {err}");
1038 }
1039
1040 #[test]
1041 fn conflicting_spellings_are_rejected() {
1042 let err = resolve_find_replace(&obj(
1043 json!({"find": "a", "old_string": "x", "replace": "b"}),
1044 ))
1045 .unwrap_err();
1046 assert!(
1047 err.contains("find") && err.contains("old_string"),
1048 "got: {err}"
1049 );
1050 let same = resolve_find_replace(&obj(
1051 json!({"find": "a", "old_string": "a", "replace": "b"}),
1052 ));
1053 assert!(same.is_ok(), "identical spellings agree");
1054 }
1055
1056 #[test]
1057 fn empty_find_is_rejected() {
1058 let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
1059 assert!(err.contains("find"), "got: {err}");
1060 }
1061
1062 #[test]
1068 fn the_reported_count_comes_from_the_admitted_text_and_the_original_is_what_is_written() {
1069 use lean_ctx::core::policy::runtime::TestPolicyOverride;
1070 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1071 let policy = lean_ctx::core::policy::load(
1072 "name = \"ctx-patch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
1073 [redaction]\ncustomer = \"CUS-[0-9]{4}\"\n\
1074 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
1075 )
1076 .unwrap();
1077 let project = tempfile::tempdir().unwrap();
1078 let root = project.path().to_string_lossy().into_owned();
1079 let file = |name: &str, content: &str| {
1080 let path = project.path().join(name);
1081 std::fs::write(&path, content).unwrap();
1082 path.to_string_lossy().into_owned()
1083 };
1084 let original = "let owner = \"CUS-1234\";\nlet mode = old_mode();\nold_mode();\n";
1085 let orders = file("orders.rs", original);
1086 let plan = file("plan.rs", "CONFIDENTIAL\nold_mode();\n");
1087 let planned = |path: &str, find: &str| {
1088 replace_all_plan(path, &root, find).map(|(pre, count)| (pre.text, count))
1089 };
1090 {
1091 let _community = TestPolicyOverride::set(None);
1093 assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1094 }
1095 let _policy = TestPolicyOverride::set(Some(policy));
1096 assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1098 let refused = replace_all_plan(&orders, &root, "CUS-1234").unwrap_err();
1100 assert!(refused.contains("refused"), "{refused}");
1101 assert!(!refused.contains("1 occurrence"), "{refused}");
1102 let withheld = replace_all_plan(&plan, &root, "old_mode").unwrap_err();
1104 assert!(withheld.contains("withheld"), "{withheld}");
1105 assert!(replace_all_plan(&orders, "", "old_mode").is_err());
1107 }
1108
1109 #[test]
1113 fn replace_all_uses_the_edit_boundary() {
1114 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1115 let project = tempfile::tempdir().unwrap();
1116 let root = project.path().to_string_lossy().into_owned();
1117 let target = project.path().join("a.rs");
1118 std::fs::write(&target, "old();\nold();\n").unwrap();
1119 let target_str = target.to_string_lossy().into_owned();
1120
1121 #[cfg(unix)]
1122 {
1123 let link = project.path().join("link.rs");
1124 std::os::unix::fs::symlink(&target, &link).unwrap();
1125 let err = replace_all_plan(&link.to_string_lossy(), &root, "old").unwrap_err();
1126 assert!(err.contains("symlink"), "{err}");
1127 }
1128
1129 let (pre, count) = replace_all_plan(&target_str, &root, "old").unwrap();
1130 assert_eq!(count, 2);
1131 std::fs::write(&target, "old();\nnewer();\n").unwrap();
1133 let new_content = pre.text.replace("old", "new");
1134 let err = replace_all_write(&target_str, &pre, &new_content).unwrap_err();
1135 assert!(err.contains("TOCTOU"), "{err}");
1136 assert_eq!(
1137 std::fs::read_to_string(&target).unwrap(),
1138 "old();\nnewer();\n"
1139 );
1140
1141 let (pre, _) = replace_all_plan(&target_str, &root, "old").unwrap();
1143 let new_content = pre.text.replace("old", "new");
1144 replace_all_write(&target_str, &pre, &new_content).unwrap();
1145 assert_eq!(
1146 std::fs::read_to_string(&target).unwrap(),
1147 "new();\nnewer();\n"
1148 );
1149 }
1150}
1151
1152#[cfg(test)]
1153mod batch_grouping_tests {
1154 use super::*;
1155 use serde_json::json;
1156
1157 #[test]
1160 fn groups_ops_across_files_preserving_order() {
1161 let ops = vec![
1162 json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
1163 json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
1164 json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
1165 ];
1166 let g = group_ops_by_path(&ops, None).unwrap();
1167 assert_eq!(g.len(), 2);
1168 assert_eq!(g[0].0, "a.go");
1169 assert_eq!(g[0].1.len(), 2);
1170 assert_eq!(g[1].0, "b.go");
1171 assert_eq!(g[1].1.len(), 1);
1172 }
1173
1174 #[test]
1175 fn ops_without_path_fall_back_to_top_level() {
1176 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1177 let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
1178 assert_eq!(g.len(), 1);
1179 assert_eq!(g[0].0, "top.go");
1180 }
1181
1182 #[test]
1183 fn op_without_path_and_no_top_level_is_rejected() {
1184 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1185 let err = group_ops_by_path(&ops, None).unwrap_err();
1186 assert!(err.contains("path"), "got: {err}");
1187 }
1188
1189 #[test]
1190 fn empty_ops_rejected() {
1191 let err = group_ops_by_path(&[], None).unwrap_err();
1192 assert!(err.contains("empty"), "got: {err}");
1193 }
1194
1195 #[test]
1196 fn cross_file_rejects_single_value_preimage_and_backup_options() {
1197 for key in ["expected_blake3", "expected_md5", "backup_path"] {
1198 let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
1199 assert!(validate_cross_file_options(&args, 2).is_err());
1200 assert!(validate_cross_file_options(&args, 1).is_ok());
1201 }
1202 }
1203
1204 #[test]
1205 fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
1206 let args = Map::from_iter([
1207 ("path".into(), json!("a.rs")),
1208 ("op".into(), json!("replace_unique")),
1209 ("old_text".into(), json!("old")),
1210 ("new_text".into(), json!("new")),
1211 ]);
1212 let mapped = build_unique_edit_args(&args).expect("valid mapping");
1213 assert_eq!(mapped.get("old_string"), Some(&json!("old")));
1214 assert_eq!(mapped.get("new_string"), Some(&json!("new")));
1215 assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
1216 assert!(!mapped.contains_key("op"));
1217 assert!(!mapped.contains_key("old_text"));
1218 }
1219
1220 #[test]
1221 fn replace_unique_requires_explicit_old_and_new_text() {
1222 assert!(build_unique_edit_args(&Map::new()).is_err());
1223 let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
1224 assert!(build_unique_edit_args(&only_old).is_err());
1225 }
1226
1227 #[test]
1228 fn dry_run_replace_unique_does_not_apply() {
1229 let args = Map::from_iter([
1230 ("path".into(), json!("a.rs")),
1231 ("op".into(), json!("replace_unique")),
1232 ("old_text".into(), json!("old")),
1233 ("new_text".into(), json!("new")),
1234 ("dry_run".into(), json!(true)),
1235 ]);
1236 let edit_args = build_unique_edit_args(&args).expect("validation passes");
1237 assert!(
1238 edit_args.contains_key("old_string"),
1239 "args mapped correctly"
1240 );
1241 assert!(
1242 args.get("dry_run").and_then(Value::as_bool) == Some(true),
1243 "dry_run flag preserved in original args"
1244 );
1245 }
1246
1247 #[test]
1248 fn text_replacement_without_op_is_inferred_as_replace_unique() {
1249 let top = Map::from_iter([
1250 ("path".into(), json!("a.rs")),
1251 ("old_string".into(), json!("old")),
1252 ("new_string".into(), json!("new")),
1253 ]);
1254 let inferred = infer_replace_unique(&top).expect("op inferred");
1255 assert_eq!(inferred.get("op"), Some(&json!("replace_unique")));
1256
1257 let batch = Map::from_iter([(
1258 "ops".into(),
1259 json!([
1260 {"path": "a.rs", "old_text": "a", "new_text": "b"},
1261 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "x"}
1262 ]),
1263 )]);
1264 let inferred = infer_replace_unique(&batch).expect("op inferred in ops[]");
1265 assert_eq!(inferred["ops"][0]["op"], json!("replace_unique"));
1266 assert_eq!(inferred["ops"][1]["op"], json!("set_line"));
1267
1268 for unchanged in [
1270 json!({"op": "set_line", "old_text": "a", "new_text": "b"}),
1271 json!({"old_text": "", "new_text": "b"}),
1272 json!({"old_text": "a"}),
1273 ] {
1274 assert!(infer_replace_unique(unchanged.as_object().unwrap()).is_none());
1275 }
1276
1277 let encoded = json!({"ops": "[{\"op\":\"replace_unique\",\"path\":\"a.rs\",\"old_text\":\"a\",\"new_text\":\"b\"}]"});
1279 let repaired = infer_replace_unique(encoded.as_object().unwrap()).expect("ops decoded");
1280 assert_eq!(repaired["ops"][0]["old_text"], json!("a"));
1281 let create = json!({"op": "create", "path": "n.py", "new_string": "x = 1\n"});
1282 let repaired = infer_replace_unique(create.as_object().unwrap()).expect("create aliased");
1283 assert_eq!(repaired["new_text"], json!("x = 1\n"));
1284 let find_replace =
1285 json!({"op": "replace_unique", "path": "a.rs", "find": "a", "replace": "b"});
1286 let repaired = infer_replace_unique(find_replace.as_object().unwrap()).expect("aliased");
1287 assert_eq!(
1288 (&repaired["old_text"], &repaired["new_text"]),
1289 (&json!("a"), &json!("b"))
1290 );
1291 let replace_all = json!({"op": "replace_all", "path": "a.rs", "find": "a", "replace": "b"});
1293 assert!(infer_replace_unique(replace_all.as_object().unwrap()).is_none());
1294 }
1295
1296 fn empty_old_text_patch(
1299 root: &std::path::Path,
1300 path: &std::path::Path,
1301 ) -> Result<String, ErrorData> {
1302 let resolved = path.to_string_lossy().into_owned();
1303 let ctx = ToolContext {
1304 project_root: root.to_string_lossy().into_owned(),
1305 resolved_paths: std::collections::HashMap::from([("path".into(), resolved.clone())]),
1306 cache: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
1307 lean_ctx::core::cache::SessionCache::new(),
1308 ))),
1309 ..ToolContext::default()
1310 };
1311 let args = json!({
1312 "op": "replace_unique", "path": resolved, "old_text": "", "new_text": "filled\n"
1313 });
1314 tokio::task::block_in_place(|| CtxPatchTool.handle(args.as_object().unwrap(), &ctx))
1315 .map(|out| out.text)
1316 }
1317
1318 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1319 async fn replace_unique_with_empty_old_text_fills_an_empty_file() {
1320 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1322 let dir = tempfile::tempdir().unwrap();
1323 let file = dir.path().join("empty.txt");
1324 std::fs::write(&file, "").unwrap();
1325 let text = empty_old_text_patch(dir.path(), &file).expect("empty file is filled");
1326 assert!(text.contains("created empty.txt"), "{text}");
1327 assert_eq!(std::fs::read_to_string(&file).unwrap(), "filled\n");
1328 }
1329
1330 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1331 async fn replace_unique_with_empty_old_text_refuses_other_targets() {
1332 let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1333 let dir = tempfile::tempdir().unwrap();
1334
1335 let full = dir.path().join("full.txt");
1336 std::fs::write(&full, "keep\n").unwrap();
1337 let err = empty_old_text_patch(dir.path(), &full).unwrap_err();
1338 assert!(err.message.contains("anchored"), "{}", err.message);
1339 assert_eq!(std::fs::read_to_string(&full).unwrap(), "keep\n");
1340
1341 let missing = dir.path().join("missing.txt");
1342 let err = empty_old_text_patch(dir.path(), &missing).unwrap_err();
1343 assert!(err.message.contains("op=create"), "{}", err.message);
1344 assert!(!missing.exists(), "replace_unique must not create files");
1345
1346 #[cfg(unix)]
1347 {
1348 let target = dir.path().join("target.txt");
1349 std::fs::write(&target, "").unwrap();
1350 let link = dir.path().join("link.txt");
1351 std::os::unix::fs::symlink(&target, &link).unwrap();
1352 let err = empty_old_text_patch(dir.path(), &link).unwrap_err();
1353 assert!(err.message.contains("not one"), "{}", err.message);
1354 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
1355 }
1356 }
1357
1358 #[test]
1359 fn replace_unique_never_forwards_a_whole_file_create() {
1360 let args = Map::from_iter([
1361 ("old_text".into(), json!("old")),
1362 ("new_text".into(), json!("new")),
1363 ("create".into(), json!(true)),
1364 ]);
1365 let mapped = build_unique_edit_args(&args).expect("valid mapping");
1366 assert!(!mapped.contains_key("create"));
1367 }
1368}