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.",
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_bool(args, "dry_run").unwrap_or(false) {
521 let old_text = get_str(args, "old_text")
522 .or_else(|| get_str(args, "old_string"))
523 .unwrap_or_default();
524 let new_text = get_str(args, "new_text")
525 .or_else(|| get_str(args, "new_string"))
526 .unwrap_or_default();
527 let path = get_str(args, "path").unwrap_or_default();
528 return Ok(ToolOutput::simple(format!(
529 "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
530 )));
531 }
532
533 crate::tools::registered::ctx_edit::handle_edit(&edit_args, ctx, host_draws_diffs(ctx))
534}
535
536fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
537 let old_text = get_str(args, "old_text")
538 .or_else(|| get_str(args, "old_string"))
539 .filter(|text| !text.is_empty())
540 .ok_or_else(|| {
541 "replace_unique requires non-empty old_text (old_string also accepted)".to_string()
542 })?;
543 let new_text = get_str(args, "new_text")
544 .or_else(|| get_str(args, "new_string"))
545 .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
546
547 let mut edit_args = args.clone();
548 edit_args.insert("old_string".into(), Value::String(old_text));
549 edit_args.insert("new_string".into(), Value::String(new_text));
550 edit_args.insert("replace_all".into(), Value::Bool(false));
551 edit_args.remove("old_text");
552 edit_args.remove("op");
553 Ok(edit_args)
554}
555
556fn validate_cross_file_options(
560 args: &Map<String, Value>,
561 file_count: usize,
562) -> Result<(), ErrorData> {
563 if file_count <= 1 {
564 return Ok(());
565 }
566 for key in ["expected_blake3", "expected_md5", "backup_path"] {
567 if args.contains_key(key) {
568 return Err(ErrorData::invalid_params(
569 format!("cross-file ctx_patch batches do not support top-level '{key}'"),
570 None,
571 ));
572 }
573 }
574 Ok(())
575}
576
577fn plan_groups(
582 args: &Map<String, Value>,
583 ctx: &ToolContext,
584) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
585 let Some(ops_val) = args.get("ops") else {
586 let path = require_resolved_path(ctx, args, "path")?;
587 let ops = crate::tools::ctx_patch::parse_ops(args)
588 .map_err(|e| ErrorData::invalid_params(e, None))?;
589 return Ok(vec![(path, ops)]);
590 };
591
592 let arr = ops_val
593 .as_array()
594 .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
595 let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
596 .map_err(|e| ErrorData::invalid_params(e, None))?;
597
598 let mut groups = Vec::with_capacity(grouped.len());
599 for (raw_path, op_objs) in grouped {
600 let resolved = ctx
601 .resolve_path_sync(&raw_path)
602 .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
603 ctx.ensure_writable(&resolved)
604 .map_err(|e| ErrorData::invalid_params(e, None))?;
605 let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
606 let ops = crate::tools::ctx_patch::parse_ops(&sub)
607 .map_err(|e| ErrorData::invalid_params(e, None))?;
608 groups.push((resolved, ops));
609 }
610 Ok(groups)
611}
612
613fn group_ops_by_path(
617 ops: &[Value],
618 top_path: Option<&str>,
619) -> Result<Vec<(String, Vec<Value>)>, String> {
620 if ops.is_empty() {
621 return Err("ops[] is empty — provide at least one edit".to_string());
622 }
623 let mut order: Vec<String> = Vec::new();
624 let mut by_path: std::collections::HashMap<String, Vec<Value>> =
625 std::collections::HashMap::new();
626 for (i, op) in ops.iter().enumerate() {
627 let obj = op
628 .as_object()
629 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
630 let raw = obj
631 .get("path")
632 .and_then(Value::as_str)
633 .map(str::to_string)
634 .or_else(|| top_path.map(str::to_string))
635 .ok_or_else(|| {
636 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
637 })?;
638 if !by_path.contains_key(&raw) {
639 order.push(raw.clone());
640 }
641 by_path.entry(raw).or_default().push(op.clone());
642 }
643 Ok(order
644 .into_iter()
645 .map(|p| {
646 let ops = by_path.remove(&p).unwrap_or_default();
647 (p, ops)
648 })
649 .collect())
650}
651
652fn apply_one(
657 ctx: &ToolContext,
658 params: &crate::tools::ctx_patch::PatchParams,
659) -> Result<String, ErrorData> {
660 let path = params.path.clone();
661 {
662 let cache_lock = ctx
663 .cache
664 .as_ref()
665 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
666
667 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
672 let _file_guard = {
673 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
674 loop {
675 if let Ok(guard) = file_lock.try_lock() {
676 break guard;
677 }
678 if std::time::Instant::now() >= deadline {
679 return Err(ErrorData::internal_error(
680 format!(
681 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
682 ),
683 None,
684 ));
685 }
686 std::thread::sleep(std::time::Duration::from_millis(20));
687 }
688 };
689
690 let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
691 {
692 Some(cache) => cache
693 .get(&path)
694 .map(|e| e.last_mode.clone())
695 .unwrap_or_default(),
696 None => String::new(),
697 };
698
699 let before = std::fs::read(&path).unwrap_or_default();
701 let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
702 let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
703
704 if written {
705 let after = std::fs::read(&path).unwrap_or_default();
706 super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &before, &after);
707 }
708
709 crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
710
711 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
712 crate::tools::ctx_read::dedup_hook::on_write(&path);
713 match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
714 Some(mut cache) => {
715 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
716 }
717 None => {
718 tracing::warn!(
719 "ctx_patch: cache write-lock timeout applying effect for {path}"
720 );
721 }
722 }
723 }
724
725 if !written {
726 return Err(tool_execution_error(output));
727 }
728
729 if let Some(session_lock) = ctx.session.as_ref() {
730 if let Some(mut session) =
731 crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
732 {
733 session.mark_modified(&path);
734 }
735 }
736
737 Ok(output)
738 }
739}
740
741fn delegate_replace_symbol(
746 args: &Map<String, Value>,
747 ctx: &ToolContext,
748) -> Result<ToolOutput, ErrorData> {
749 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
750 .map_err(|e| ErrorData::invalid_params(e, None))?;
751
752 if get_bool(args, "dry_run").unwrap_or(false) {
753 let name = get_str(args, "name").unwrap_or_default();
754 let path = get_str(args, "path").unwrap_or_default();
755 return Ok(ToolOutput::simple(format!(
756 "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
757 )));
758 }
759
760 let has_path = args.get("path").and_then(Value::as_str).is_some();
764 let abs_path = if has_path {
765 require_resolved_path(ctx, args, "path")?
766 } else {
767 String::new()
768 };
769
770 let args_value = Value::Object(refactor_args);
771 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
772 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
773 if !changed {
774 return Err(tool_execution_error(result));
775 }
776
777 Ok(ToolOutput {
778 text: result,
779 original_tokens: 0,
780 saved_tokens: 0,
781 mode: Some("replace_symbol".to_string()),
782 path: get_str(args, "path"),
783 changed,
784 shell_outcome: None,
785 content_blocks: None,
786 })
787}
788
789fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
801 let find = aliased(args, "find", &["old_string", "old_text"])?
802 .filter(|s| !s.is_empty())
803 .ok_or("replace_all requires non-empty 'find' (old_string also accepted)")?;
804 let replace = aliased(args, "replace", &["new_string", "new_text"])?.ok_or(
805 "replace_all requires 'replace' (the replacement text, new_string also accepted); \
806 pass replace=\"\" explicitly to delete every match",
807 )?;
808 Ok((find, replace))
809}
810
811fn aliased(
814 args: &Map<String, Value>,
815 key: &str,
816 aliases: &[&str],
817) -> Result<Option<String>, String> {
818 let mut found: Option<(&str, &str)> = None;
819 for name in std::iter::once(key).chain(aliases.iter().copied()) {
820 let Some(value) = args.get(name).and_then(Value::as_str) else {
821 continue;
822 };
823 match found {
824 Some((first, seen)) if seen != value => {
825 return Err(format!(
826 "'{first}' and '{name}' name the same field with different values — pass only one"
827 ));
828 }
829 Some(_) => {}
830 None => found = Some((name, value)),
831 }
832 }
833 Ok(found.map(|(_, value)| value.to_string()))
834}
835
836fn replace_all_plan(
853 path: &str,
854 root: &str,
855 find: &str,
856) -> Result<(crate::tools::edit_io::FilePreimage, usize), String> {
857 use crate::tools::admitted_sources::SourceRead;
858 let cap = lean_ctx::core::limits::max_read_bytes();
859 let original = crate::tools::edit_io::read_preimage(std::path::Path::new(path), cap, false)?;
860 let mut remaining = cap;
861 let admitted =
862 match crate::tools::admitted_sources::read(path, root, "ctx_patch", &mut remaining) {
863 SourceRead::Admitted(read) => read.content,
864 SourceRead::Withheld(reason) | SourceRead::Unavailable(reason) => {
865 return Err(format!("cannot read {path}: {reason}"));
866 }
867 };
868 let count = admitted.matches(find).count();
869 if count != original.text.matches(find).count() {
870 return Err(format!(
871 "replace_all refused for {path}: the pattern overlaps content the context gateway \
872 masks; nothing was changed"
873 ));
874 }
875 Ok((original, count))
876}
877
878fn replace_all_write(
882 path: &str,
883 pre: &crate::tools::edit_io::FilePreimage,
884 new_content: &str,
885) -> Result<(), String> {
886 let path = std::path::Path::new(path);
887 crate::tools::edit_io::ensure_preimage_still_matches(
888 path,
889 &pre.fp,
890 lean_ctx::core::limits::max_read_bytes(),
891 )?;
892 crate::tools::edit_io::write_atomic_bytes_with_permissions(
893 path,
894 new_content.as_bytes(),
895 Some(&pre.permissions),
896 )
897}
898
899fn handle_replace_all(
901 args: &Map<String, Value>,
902 ctx: &ToolContext,
903) -> Result<ToolOutput, ErrorData> {
904 let path = require_resolved_path(ctx, args, "path")?;
905 let (find, replace) =
906 resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
907 let dry_run = get_bool(args, "dry_run").unwrap_or(false);
908
909 let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
912 let _guard = file_lock
913 .lock()
914 .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
915
916 let root = crate::tools::admitted_sources::root_in_context(&path, ctx);
917 let (pre, count) = replace_all_plan(&path, &root, &find).map_err(tool_execution_error)?;
918 if count == 0 {
919 return Ok(ToolOutput::simple(format!(
920 "No matches for {find:?} in {path}"
921 )));
922 }
923
924 if dry_run {
925 return Ok(ToolOutput::simple(format!(
926 "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
927 )));
928 }
929
930 let new_content = pre.text.replace(find.as_str(), &replace);
931 replace_all_write(&path, &pre, &new_content).map_err(tool_execution_error)?;
932 super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &pre.bytes, new_content.as_bytes());
933
934 crate::tools::ctx_read::dedup_hook::on_write(&path);
935 if let Some(cache) = ctx.cache.as_ref() {
936 if let Some(mut c) =
937 crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
938 {
939 c.invalidate(&path);
940 }
941 }
942 if let Some(session_lock) = ctx.session.as_ref() {
943 if let Some(mut session) =
944 crate::server::bounded_lock::write(session_lock, "ctx_patch replace_all session")
945 {
946 session.mark_modified(&path);
947 }
948 }
949
950 Ok(ToolOutput::simple(format!(
951 "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
952 )))
953}
954
955#[cfg(test)]
956mod replace_all_tests {
957 use super::*;
958 use serde_json::json;
959
960 fn obj(v: Value) -> Map<String, Value> {
961 match v {
962 Value::Object(m) => m,
963 _ => panic!("expected object"),
964 }
965 }
966
967 #[test]
968 fn resolves_find_and_replace() {
969 let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
970 assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
971 }
972
973 #[test]
974 fn explicit_empty_replace_is_a_deletion() {
975 let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
976 assert_eq!(r, "");
977 }
978
979 #[test]
980 fn missing_replace_is_rejected_not_silent_delete() {
981 let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
982 assert!(err.contains("requires 'replace'"), "got: {err}");
983 }
984
985 #[test]
986 fn other_ops_names_are_aliases() {
987 for (old, new) in [("old_string", "new_string"), ("old_text", "new_text")] {
988 let (f, r) = resolve_find_replace(&obj(json!({old: "a", new: "b"}))).unwrap();
989 assert_eq!((f.as_str(), r.as_str()), ("a", "b"), "{old}/{new}");
990 }
991 }
992
993 #[test]
994 fn alias_never_turns_a_missing_replacement_into_a_delete() {
995 let err = resolve_find_replace(&obj(json!({"old_string": "a"}))).unwrap_err();
996 assert!(err.contains("requires 'replace'"), "got: {err}");
997 }
998
999 #[test]
1000 fn conflicting_spellings_are_rejected() {
1001 let err = resolve_find_replace(&obj(
1002 json!({"find": "a", "old_string": "x", "replace": "b"}),
1003 ))
1004 .unwrap_err();
1005 assert!(
1006 err.contains("find") && err.contains("old_string"),
1007 "got: {err}"
1008 );
1009 let same = resolve_find_replace(&obj(
1010 json!({"find": "a", "old_string": "a", "replace": "b"}),
1011 ));
1012 assert!(same.is_ok(), "identical spellings agree");
1013 }
1014
1015 #[test]
1016 fn empty_find_is_rejected() {
1017 let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
1018 assert!(err.contains("find"), "got: {err}");
1019 }
1020
1021 #[test]
1027 fn the_reported_count_comes_from_the_admitted_text_and_the_original_is_what_is_written() {
1028 use lean_ctx::core::policy::runtime::TestPolicyOverride;
1029 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1030 let policy = lean_ctx::core::policy::load(
1031 "name = \"ctx-patch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
1032 [redaction]\ncustomer = \"CUS-[0-9]{4}\"\n\
1033 [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
1034 )
1035 .unwrap();
1036 let project = tempfile::tempdir().unwrap();
1037 let root = project.path().to_string_lossy().into_owned();
1038 let file = |name: &str, content: &str| {
1039 let path = project.path().join(name);
1040 std::fs::write(&path, content).unwrap();
1041 path.to_string_lossy().into_owned()
1042 };
1043 let original = "let owner = \"CUS-1234\";\nlet mode = old_mode();\nold_mode();\n";
1044 let orders = file("orders.rs", original);
1045 let plan = file("plan.rs", "CONFIDENTIAL\nold_mode();\n");
1046 let planned = |path: &str, find: &str| {
1047 replace_all_plan(path, &root, find).map(|(pre, count)| (pre.text, count))
1048 };
1049 {
1050 let _community = TestPolicyOverride::set(None);
1052 assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1053 }
1054 let _policy = TestPolicyOverride::set(Some(policy));
1055 assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1057 let refused = replace_all_plan(&orders, &root, "CUS-1234").unwrap_err();
1059 assert!(refused.contains("refused"), "{refused}");
1060 assert!(!refused.contains("1 occurrence"), "{refused}");
1061 let withheld = replace_all_plan(&plan, &root, "old_mode").unwrap_err();
1063 assert!(withheld.contains("withheld"), "{withheld}");
1064 assert!(replace_all_plan(&orders, "", "old_mode").is_err());
1066 }
1067
1068 #[test]
1072 fn replace_all_uses_the_edit_boundary() {
1073 let _data = lean_ctx::core::data_dir::isolated_data_dir();
1074 let project = tempfile::tempdir().unwrap();
1075 let root = project.path().to_string_lossy().into_owned();
1076 let target = project.path().join("a.rs");
1077 std::fs::write(&target, "old();\nold();\n").unwrap();
1078 let target_str = target.to_string_lossy().into_owned();
1079
1080 #[cfg(unix)]
1081 {
1082 let link = project.path().join("link.rs");
1083 std::os::unix::fs::symlink(&target, &link).unwrap();
1084 let err = replace_all_plan(&link.to_string_lossy(), &root, "old").unwrap_err();
1085 assert!(err.contains("symlink"), "{err}");
1086 }
1087
1088 let (pre, count) = replace_all_plan(&target_str, &root, "old").unwrap();
1089 assert_eq!(count, 2);
1090 std::fs::write(&target, "old();\nnewer();\n").unwrap();
1092 let new_content = pre.text.replace("old", "new");
1093 let err = replace_all_write(&target_str, &pre, &new_content).unwrap_err();
1094 assert!(err.contains("TOCTOU"), "{err}");
1095 assert_eq!(
1096 std::fs::read_to_string(&target).unwrap(),
1097 "old();\nnewer();\n"
1098 );
1099
1100 let (pre, _) = replace_all_plan(&target_str, &root, "old").unwrap();
1102 let new_content = pre.text.replace("old", "new");
1103 replace_all_write(&target_str, &pre, &new_content).unwrap();
1104 assert_eq!(
1105 std::fs::read_to_string(&target).unwrap(),
1106 "new();\nnewer();\n"
1107 );
1108 }
1109}
1110
1111#[cfg(test)]
1112mod batch_grouping_tests {
1113 use super::*;
1114 use serde_json::json;
1115
1116 #[test]
1119 fn groups_ops_across_files_preserving_order() {
1120 let ops = vec![
1121 json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
1122 json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
1123 json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
1124 ];
1125 let g = group_ops_by_path(&ops, None).unwrap();
1126 assert_eq!(g.len(), 2);
1127 assert_eq!(g[0].0, "a.go");
1128 assert_eq!(g[0].1.len(), 2);
1129 assert_eq!(g[1].0, "b.go");
1130 assert_eq!(g[1].1.len(), 1);
1131 }
1132
1133 #[test]
1134 fn ops_without_path_fall_back_to_top_level() {
1135 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1136 let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
1137 assert_eq!(g.len(), 1);
1138 assert_eq!(g[0].0, "top.go");
1139 }
1140
1141 #[test]
1142 fn op_without_path_and_no_top_level_is_rejected() {
1143 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1144 let err = group_ops_by_path(&ops, None).unwrap_err();
1145 assert!(err.contains("path"), "got: {err}");
1146 }
1147
1148 #[test]
1149 fn empty_ops_rejected() {
1150 let err = group_ops_by_path(&[], None).unwrap_err();
1151 assert!(err.contains("empty"), "got: {err}");
1152 }
1153
1154 #[test]
1155 fn cross_file_rejects_single_value_preimage_and_backup_options() {
1156 for key in ["expected_blake3", "expected_md5", "backup_path"] {
1157 let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
1158 assert!(validate_cross_file_options(&args, 2).is_err());
1159 assert!(validate_cross_file_options(&args, 1).is_ok());
1160 }
1161 }
1162
1163 #[test]
1164 fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
1165 let args = Map::from_iter([
1166 ("path".into(), json!("a.rs")),
1167 ("op".into(), json!("replace_unique")),
1168 ("old_text".into(), json!("old")),
1169 ("new_text".into(), json!("new")),
1170 ]);
1171 let mapped = build_unique_edit_args(&args).expect("valid mapping");
1172 assert_eq!(mapped.get("old_string"), Some(&json!("old")));
1173 assert_eq!(mapped.get("new_string"), Some(&json!("new")));
1174 assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
1175 assert!(!mapped.contains_key("op"));
1176 assert!(!mapped.contains_key("old_text"));
1177 }
1178
1179 #[test]
1180 fn replace_unique_requires_explicit_old_and_new_text() {
1181 assert!(build_unique_edit_args(&Map::new()).is_err());
1182 let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
1183 assert!(build_unique_edit_args(&only_old).is_err());
1184 }
1185
1186 #[test]
1187 fn dry_run_replace_unique_does_not_apply() {
1188 let args = Map::from_iter([
1189 ("path".into(), json!("a.rs")),
1190 ("op".into(), json!("replace_unique")),
1191 ("old_text".into(), json!("old")),
1192 ("new_text".into(), json!("new")),
1193 ("dry_run".into(), json!(true)),
1194 ]);
1195 let edit_args = build_unique_edit_args(&args).expect("validation passes");
1196 assert!(
1197 edit_args.contains_key("old_string"),
1198 "args mapped correctly"
1199 );
1200 assert!(
1201 args.get("dry_run").and_then(Value::as_bool) == Some(true),
1202 "dry_run flag preserved in original args"
1203 );
1204 }
1205
1206 #[test]
1207 fn text_replacement_without_op_is_inferred_as_replace_unique() {
1208 let top = Map::from_iter([
1209 ("path".into(), json!("a.rs")),
1210 ("old_string".into(), json!("old")),
1211 ("new_string".into(), json!("new")),
1212 ]);
1213 let inferred = infer_replace_unique(&top).expect("op inferred");
1214 assert_eq!(inferred.get("op"), Some(&json!("replace_unique")));
1215
1216 let batch = Map::from_iter([(
1217 "ops".into(),
1218 json!([
1219 {"path": "a.rs", "old_text": "a", "new_text": "b"},
1220 {"op": "set_line", "line": 1, "hash": "aa", "new_text": "x"}
1221 ]),
1222 )]);
1223 let inferred = infer_replace_unique(&batch).expect("op inferred in ops[]");
1224 assert_eq!(inferred["ops"][0]["op"], json!("replace_unique"));
1225 assert_eq!(inferred["ops"][1]["op"], json!("set_line"));
1226
1227 for unchanged in [
1229 json!({"op": "set_line", "old_text": "a", "new_text": "b"}),
1230 json!({"old_text": "", "new_text": "b"}),
1231 json!({"old_text": "a"}),
1232 ] {
1233 assert!(infer_replace_unique(unchanged.as_object().unwrap()).is_none());
1234 }
1235
1236 let encoded = json!({"ops": "[{\"op\":\"replace_unique\",\"path\":\"a.rs\",\"old_text\":\"a\",\"new_text\":\"b\"}]"});
1238 let repaired = infer_replace_unique(encoded.as_object().unwrap()).expect("ops decoded");
1239 assert_eq!(repaired["ops"][0]["old_text"], json!("a"));
1240 let create = json!({"op": "create", "path": "n.py", "new_string": "x = 1\n"});
1241 let repaired = infer_replace_unique(create.as_object().unwrap()).expect("create aliased");
1242 assert_eq!(repaired["new_text"], json!("x = 1\n"));
1243 let find_replace =
1244 json!({"op": "replace_unique", "path": "a.rs", "find": "a", "replace": "b"});
1245 let repaired = infer_replace_unique(find_replace.as_object().unwrap()).expect("aliased");
1246 assert_eq!(
1247 (&repaired["old_text"], &repaired["new_text"]),
1248 (&json!("a"), &json!("b"))
1249 );
1250 let replace_all = json!({"op": "replace_all", "path": "a.rs", "find": "a", "replace": "b"});
1252 assert!(infer_replace_unique(replace_all.as_object().unwrap()).is_none());
1253 }
1254}