Skip to main content

lean_ctx/tools/registered/
ctx_patch.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6    McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxPatchTool;
11
12impl McpTool for CtxPatchTool {
13    fn name(&self) -> &'static str {
14        "ctx_patch"
15    }
16
17    // Schema diet (#576 pattern): the advertised surface carries only the
18    // functional teaching (anchor source, op routing, batch atomicity).
19    // Handler-only params stay supported but unadvertised: expected_md5,
20    // backup, backup_path, validate_syntax, evidence, diff_max_lines,
21    // allow_lossy_utf8 — same hidden-params contract as ctx_edit.
22    fn tool_def(&self) -> Tool {
23        tool_def(
24            "ctx_patch",
25            "Safe file edit. Anchored ops use line+hash from ctx_read(mode=\"anchored\"); \
26             CONFLICT means re-read. replace_unique(path,old_text,new_text) is a no-read, \
27             exact unique replacement. replace_symbol/create/replace_all and cross-file ops[] \
28             (incl. replace_unique) supported.",
29            json!({
30                "type": "object",
31                "properties": {
32                    "path": { "type": "string" },
33                    "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_unique", "replace_symbol", "create", "replace_all"] },
34                    "line": { "type": "integer" },
35                    "hash": { "type": "string" },
36                    "start_line": { "type": "integer" },
37                    "start_hash": { "type": "string" },
38                    "end_line": { "type": "integer" },
39                    "end_hash": { "type": "string" },
40                    "new_text": { "type": "string" },
41                    "old_text": { "type": "string" },
42                    "name": { "type": "string" },
43                    "find": { "type": "string" },
44                    "replace": { "type": "string" },
45                    "dry_run": { "type": "boolean" },
46                    "ops": { "type": "array", "items": { "type": "object" } }
47                },
48                // Per-op required params encoded as the source of truth (#1020):
49                // a client reading the schema knows which fields an op needs
50                // BEFORE calling. Each `if` requires `op` so it stays dormant for
51                // batch calls (which carry `op` inside ops[], not at top level).
52                // Only ops with UNCONDITIONAL requirements are listed — insert_after
53                // (hash optional when line=0) and delete (single vs range) keep their
54                // requirements in the parser to avoid rejecting valid calls.
55                "allOf": [
56                    { "if": { "properties": { "op": { "const": "set_line" } }, "required": ["op"] },
57                      "then": { "required": ["op", "line", "hash", "new_text"] } },
58                    { "if": { "properties": { "op": { "const": "replace_lines" } }, "required": ["op"] },
59                      "then": { "required": ["op", "start_line", "start_hash", "end_line", "end_hash", "new_text"] } },
60                    { "if": { "properties": { "op": { "const": "replace_unique" } }, "required": ["op"] },
61                      "then": { "required": ["op", "old_text", "new_text"] } },
62                    { "if": { "properties": { "op": { "const": "replace_symbol" } }, "required": ["op"] },
63                      "then": { "required": ["op", "new_text"] } },
64                    { "if": { "properties": { "op": { "const": "create" } }, "required": ["op"] },
65                      "then": { "required": ["op", "new_text"] } },
66                    { "if": { "properties": { "op": { "const": "replace_all" } }, "required": ["op"] },
67                      "then": { "required": ["op", "find", "replace"] } }
68                ]
69            }),
70        )
71    }
72
73    fn handle(
74        &self,
75        args: &Map<String, Value>,
76        ctx: &ToolContext,
77    ) -> Result<ToolOutput, ErrorData> {
78        // replace_symbol is a whole-symbol rewrite — delegate to the LSP/IDE-aware
79        // ctx_refactor so there is one symbol-edit implementation (epic #1008).
80        if crate::tools::ctx_patch::is_replace_symbol(args) {
81            return delegate_replace_symbol(args, ctx);
82        }
83
84        if get_str(args, "op").as_deref() == Some("replace_unique") {
85            return delegate_replace_unique(args, ctx);
86        }
87
88        // #825: replace_all short-circuits before anchor parsing.
89        if get_str(args, "op").as_deref() == Some("replace_all") {
90            return handle_replace_all(args, ctx);
91        }
92
93        // #1088: replace_unique/replace_symbol are allowed inside ops[]. Split
94        // the batch into runs: each delegated op reuses its single-op write
95        // path; consecutive anchored ops keep shared-preimage batch semantics.
96        if let Some(arr) = args.get("ops").and_then(Value::as_array) {
97            // GH #1433: pre-validate before any ops are applied.
98            for (i, v) in arr.iter().enumerate() {
99                if let Some(kind) = is_unbatchable_op(v) {
100                    return Err(ErrorData::invalid_params(
101                        format!(
102                            "ops[{i}]: {kind} cannot be batched in ops[] \
103                             — send it as a separate top-level ctx_patch call"
104                        ),
105                        None,
106                    ));
107                }
108            }
109            if arr.iter().any(|v| delegated_op_kind(v).is_some()) {
110                return handle_mixed_batch(args, arr, ctx);
111            }
112        }
113
114        handle_anchored(args, ctx)
115    }
116}
117
118/// Anchored pipeline: a single anchored op or an ops[] batch of anchored edits
119/// (grouped per file, batch-atomic per file). Split out of `handle` so the
120/// mixed-batch path (#1088) can reuse it per anchored run.
121fn handle_anchored(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
122    if get_bool(args, "dry_run").unwrap_or(false) {
123        let path = get_str(args, "path").unwrap_or_default();
124        return Ok(ToolOutput::simple(format!(
125            "DRY RUN: ctx_patch would apply anchor-based ops to {path}"
126        )));
127    }
128
129    let expected_md5 = get_str(args, "expected_md5");
130    let backup = get_bool(args, "backup").unwrap_or(false);
131    let backup_path = get_str(args, "backup_path")
132        .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
133    let evidence = get_bool(args, "evidence").unwrap_or(true);
134    let diff_max_lines = get_int(args, "diff_max_lines")
135        .and_then(|v| usize::try_from(v.max(0)).ok())
136        .unwrap_or(200);
137    let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
138    let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
139
140    // #1005: a batch (`ops[]`) groups its ops by each op's own `path`, so a
141    // batch may span files and needs no top-level `path`. A single op still
142    // requires the top-level `path`.
143    let groups = plan_groups(args, ctx)?;
144    validate_cross_file_options(args, groups.len())?;
145    let output_path = (groups.len() == 1).then(|| groups[0].0.clone());
146
147    let mut texts = Vec::with_capacity(groups.len());
148    for (path, ops) in groups {
149        let patch_params = crate::tools::ctx_patch::PatchParams {
150            path: path.clone(),
151            ops,
152            expected_md5: expected_md5.clone(),
153            backup,
154            backup_path: backup_path.clone(),
155            evidence,
156            diff_max_lines,
157            allow_lossy_utf8,
158            validate_syntax,
159        };
160        let output = apply_one(ctx, &patch_params)?;
161        texts.push(format!("[{path}]\n{output}"));
162    }
163
164    Ok(ToolOutput {
165        text: texts.join("\n\n"),
166        original_tokens: 0,
167        saved_tokens: 0,
168        mode: None,
169        path: output_path,
170        changed: false,
171        shell_outcome: None,
172        content_blocks: None,
173    })
174}
175
176/// `Some(op)` when a batch op must be dispatched through its own single-op
177/// write path rather than the anchored pipeline (#1088).
178fn delegated_op_kind(v: &Value) -> Option<&str> {
179    v.get("op")
180        .and_then(Value::as_str)
181        .filter(|k| matches!(*k, "replace_unique" | "replace_symbol"))
182}
183
184/// #1088: an ops[] batch containing replace_unique/replace_symbol. Ops apply
185/// strictly in the order given, as if issued as separate calls: consecutive
186/// anchored ops flush as one shared-preimage batch; each delegated op is
187/// dispatched through the same delegate as its top-level form, evaluated
188/// against the file state left by the preceding ops.
189/// Ops that require their own top-level ctx_patch call and cannot be
190/// batched inside ops[].
191fn is_unbatchable_op(v: &Value) -> Option<&str> {
192    v.get("op")
193        .and_then(Value::as_str)
194        .filter(|k| matches!(*k, "replace_all" | "create"))
195}
196
197fn handle_mixed_batch(
198    args: &Map<String, Value>,
199    arr: &[Value],
200    ctx: &ToolContext,
201) -> Result<ToolOutput, ErrorData> {
202    // GH #1433: pre-validate the entire batch before applying any op.
203    for (i, v) in arr.iter().enumerate() {
204        if !v.is_object() {
205            return Err(ErrorData::invalid_params(
206                format!("ops[{i}] must be an object"),
207                None,
208            ));
209        }
210        if let Some(kind) = is_unbatchable_op(v) {
211            return Err(ErrorData::invalid_params(
212                format!(
213                    "ops[{i}]: {kind} cannot be batched in ops[] \
214                     — send it as a separate top-level ctx_patch call"
215                ),
216                None,
217            ));
218        }
219    }
220
221    let mut texts: Vec<String> = Vec::new();
222    let mut run: Vec<Value> = Vec::new();
223    for (i, v) in arr.iter().enumerate() {
224        let obj = v.as_object().ok_or_else(|| {
225            ErrorData::invalid_params(format!("ops[{i}] must be an object"), None)
226        })?;
227        let Some(kind) = delegated_op_kind(v) else {
228            run.push(v.clone());
229            continue;
230        };
231        flush_anchored_run(args, ctx, &mut run, &mut texts)?;
232
233        let (sub_args, sub_ctx) = delegated_op_call(args, obj, ctx, i, kind)?;
234        let out = if kind == "replace_unique" {
235            delegate_replace_unique(&sub_args, &sub_ctx)
236        } else {
237            delegate_replace_symbol(&sub_args, &sub_ctx)
238        }
239        .map_err(|e| {
240            let applied = if texts.is_empty() {
241                ""
242            } else {
243                " (earlier ops in this batch were already applied)"
244            };
245            ErrorData::invalid_params(format!("ops[{i}] ({kind}): {}{applied}", e.message), None)
246        })?;
247        let label = get_str(&sub_args, "path").unwrap_or_else(|| kind.to_string());
248        texts.push(format!("[{label}]\n{}", out.text));
249    }
250    flush_anchored_run(args, ctx, &mut run, &mut texts)?;
251
252    Ok(ToolOutput::simple(texts.join("\n\n")))
253}
254
255/// Flush buffered anchored ops through the anchored pipeline as one batch.
256fn flush_anchored_run(
257    args: &Map<String, Value>,
258    ctx: &ToolContext,
259    run: &mut Vec<Value>,
260    texts: &mut Vec<String>,
261) -> Result<(), ErrorData> {
262    if run.is_empty() {
263        return Ok(());
264    }
265    let mut sub = args.clone();
266    sub.insert("ops".into(), Value::Array(std::mem::take(run)));
267    texts.push(handle_anchored(&sub, ctx)?.text);
268    Ok(())
269}
270
271/// Build the (args, ctx) for one delegated batch op: the op object inherits the
272/// batch's top-level `path`/`dry_run` when it doesn't set its own, and the op's
273/// path is resolved into a per-op ctx — the dispatch layer only pre-resolves
274/// the top-level `path`, which may differ or be absent (#1088).
275fn delegated_op_call(
276    args: &Map<String, Value>,
277    op: &Map<String, Value>,
278    ctx: &ToolContext,
279    i: usize,
280    kind: &str,
281) -> Result<(Map<String, Value>, ToolContext), ErrorData> {
282    let mut sub = op.clone();
283    for key in ["path", "dry_run"] {
284        if !sub.contains_key(key)
285            && let Some(v) = args.get(key)
286        {
287            sub.insert(key.to_string(), v.clone());
288        }
289    }
290
291    let mut sub_ctx = ctx.clone();
292    sub_ctx.resolved_paths.remove("path");
293    sub_ctx.path_errors.remove("path");
294    match get_str(&sub, "path") {
295        Some(raw) => {
296            let resolved = sub_ctx
297                .resolve_path_sync(&raw)
298                .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: path: {e}"), None))?;
299            sub_ctx
300                .ensure_writable(&resolved)
301                .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: {e}"), None))?;
302            sub_ctx.resolved_paths.insert("path".to_string(), resolved);
303        }
304        // replace_symbol's `name` route resolves its own path inside
305        // ctx_refactor; replace_unique always needs one.
306        None if kind == "replace_unique" => {
307            return Err(ErrorData::invalid_params(
308                format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)"),
309                None,
310            ));
311        }
312        None => {}
313    }
314    Ok((sub, sub_ctx))
315}
316
317/// One-shot content-anchored edit (#1010). Delegate to ctx_edit's audited
318/// unique-replacement path so ambiguity checks, TOCTOU guards, atomic writes,
319/// cache invalidation, evidence and session tracking remain single-sourced.
320fn delegate_replace_unique(
321    args: &Map<String, Value>,
322    ctx: &ToolContext,
323) -> Result<ToolOutput, ErrorData> {
324    let edit_args =
325        build_unique_edit_args(args).map_err(|message| ErrorData::invalid_params(message, None))?;
326
327    if get_bool(args, "dry_run").unwrap_or(false) {
328        let old_text = get_str(args, "old_text")
329            .or_else(|| get_str(args, "old_string"))
330            .unwrap_or_default();
331        let new_text = get_str(args, "new_text")
332            .or_else(|| get_str(args, "new_string"))
333            .unwrap_or_default();
334        let path = get_str(args, "path").unwrap_or_default();
335        return Ok(ToolOutput::simple(format!(
336            "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
337        )));
338    }
339
340    crate::tools::registered::ctx_edit::CtxEditTool.handle(&edit_args, ctx)
341}
342
343fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
344    let old_text = get_str(args, "old_text")
345        .or_else(|| get_str(args, "old_string"))
346        .filter(|text| !text.is_empty())
347        .ok_or_else(|| {
348            "replace_unique requires non-empty old_text (old_string also accepted)".to_string()
349        })?;
350    let new_text = get_str(args, "new_text")
351        .or_else(|| get_str(args, "new_string"))
352        .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
353
354    let mut edit_args = args.clone();
355    edit_args.insert("old_string".into(), Value::String(old_text));
356    edit_args.insert("new_string".into(), Value::String(new_text));
357    edit_args.insert("replace_all".into(), Value::Bool(false));
358    edit_args.remove("old_text");
359    edit_args.remove("op");
360    Ok(edit_args)
361}
362
363/// Options with one top-level value cannot be applied safely to multiple files:
364/// one digest cannot describe several preimages, and one explicit backup path
365/// would be overwritten by the second file. Reject before any write occurs.
366fn validate_cross_file_options(
367    args: &Map<String, Value>,
368    file_count: usize,
369) -> Result<(), ErrorData> {
370    if file_count <= 1 {
371        return Ok(());
372    }
373    for key in ["expected_md5", "backup_path"] {
374        if args.contains_key(key) {
375            return Err(ErrorData::invalid_params(
376                format!("cross-file ctx_patch batches do not support top-level '{key}'"),
377                None,
378            ));
379        }
380    }
381    Ok(())
382}
383
384/// Build the per-file work list. A single op targets the top-level `path`
385/// (still required). A batch (`ops[]`) groups its ops by each op's own `path`,
386/// falling back to the top-level `path`, so a batch may span files without a
387/// top-level `path` (#1005).
388fn plan_groups(
389    args: &Map<String, Value>,
390    ctx: &ToolContext,
391) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
392    let Some(ops_val) = args.get("ops") else {
393        let path = require_resolved_path(ctx, args, "path")?;
394        let ops = crate::tools::ctx_patch::parse_ops(args)
395            .map_err(|e| ErrorData::invalid_params(e, None))?;
396        return Ok(vec![(path, ops)]);
397    };
398
399    let arr = ops_val
400        .as_array()
401        .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
402    let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
403        .map_err(|e| ErrorData::invalid_params(e, None))?;
404
405    let mut groups = Vec::with_capacity(grouped.len());
406    for (raw_path, op_objs) in grouped {
407        let resolved = ctx
408            .resolve_path_sync(&raw_path)
409            .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
410        ctx.ensure_writable(&resolved)
411            .map_err(|e| ErrorData::invalid_params(e, None))?;
412        let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
413        let ops = crate::tools::ctx_patch::parse_ops(&sub)
414            .map_err(|e| ErrorData::invalid_params(e, None))?;
415        groups.push((resolved, ops));
416    }
417    Ok(groups)
418}
419
420/// Pure grouping: bucket batch op objects by their own `path` (or the batch's
421/// top-level `path` fallback), preserving first-seen file order. Errors if an op
422/// names no path and there is no top-level fallback.
423fn group_ops_by_path(
424    ops: &[Value],
425    top_path: Option<&str>,
426) -> Result<Vec<(String, Vec<Value>)>, String> {
427    if ops.is_empty() {
428        return Err("ops[] is empty — provide at least one edit".to_string());
429    }
430    let mut order: Vec<String> = Vec::new();
431    let mut by_path: std::collections::HashMap<String, Vec<Value>> =
432        std::collections::HashMap::new();
433    for (i, op) in ops.iter().enumerate() {
434        let obj = op
435            .as_object()
436            .ok_or_else(|| format!("ops[{i}] must be an object"))?;
437        let raw = obj
438            .get("path")
439            .and_then(Value::as_str)
440            .map(str::to_string)
441            .or_else(|| top_path.map(str::to_string))
442            .ok_or_else(|| {
443                format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
444            })?;
445        if !by_path.contains_key(&raw) {
446            order.push(raw.clone());
447        }
448        by_path.entry(raw).or_default().push(op.clone());
449    }
450    Ok(order
451        .into_iter()
452        .map(|p| {
453            let ops = by_path.remove(&p).unwrap_or_default();
454            (p, ops)
455        })
456        .collect())
457}
458
459/// Apply one file's anchored patch: acquire the per-file lock, run the I/O off
460/// the global cache lock, then fold the resulting cache effect and session mark.
461/// Returns the rendered patch/CONFLICT text. Shared by the single-op and the
462/// per-file batch paths (#1005).
463fn apply_one(
464    ctx: &ToolContext,
465    params: &crate::tools::ctx_patch::PatchParams,
466) -> Result<String, ErrorData> {
467    let path = params.path.clone();
468    {
469        let cache_lock = ctx
470            .cache
471            .as_ref()
472            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
473
474        // Serialize edits to the SAME file via the shared per-file lock (the same
475        // registry ctx_edit/ctx_read use), so anchored and str_replace edits of
476        // one file never interleave (issue #320). Correctness across processes
477        // still rests on the TOCTOU preimage guard + atomic rename.
478        let file_lock = crate::core::path_locks::per_file_lock(&path);
479        let _file_guard = {
480            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
481            loop {
482                if let Ok(guard) = file_lock.try_lock() {
483                    break guard;
484                }
485                if std::time::Instant::now() >= deadline {
486                    return Err(ErrorData::internal_error(
487                        format!(
488                            "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
489                        ),
490                        None,
491                    ));
492                }
493                std::thread::sleep(std::time::Duration::from_millis(20));
494            }
495        };
496
497        let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
498        {
499            Some(cache) => cache
500                .get(&path)
501                .map(|e| e.last_mode.clone())
502                .unwrap_or_default(),
503            None => String::new(),
504        };
505
506        // Heavy disk I/O — no global cache lock held here.
507        let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
508
509        crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
510
511        if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
512            crate::tools::ctx_read::dedup_hook::on_write(&path);
513            match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
514                Some(mut cache) => {
515                    crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
516                }
517                None => {
518                    tracing::warn!(
519                        "ctx_patch: cache write-lock timeout applying effect for {path}"
520                    );
521                }
522            }
523        }
524
525        if let Some(session_lock) = ctx.session.as_ref() {
526            if let Some(mut session) =
527                crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
528            {
529                session.mark_modified(&path);
530            }
531        }
532
533        Ok(output)
534    }
535}
536
537/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
538/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
539/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
540/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
541fn delegate_replace_symbol(
542    args: &Map<String, Value>,
543    ctx: &ToolContext,
544) -> Result<ToolOutput, ErrorData> {
545    let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
546        .map_err(|e| ErrorData::invalid_params(e, None))?;
547
548    if get_bool(args, "dry_run").unwrap_or(false) {
549        let name = get_str(args, "name").unwrap_or_default();
550        let path = get_str(args, "path").unwrap_or_default();
551        return Ok(ToolOutput::simple(format!(
552            "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
553        )));
554    }
555
556    // Resolve `path` at the boundary when given (the name route resolves its own
557    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
558    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
559    let has_path = args.get("path").and_then(Value::as_str).is_some();
560    let abs_path = if has_path {
561        require_resolved_path(ctx, args, "path")?
562    } else {
563        String::new()
564    };
565
566    let args_value = Value::Object(refactor_args);
567    let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
568    let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
569
570    Ok(ToolOutput {
571        text: result,
572        original_tokens: 0,
573        saved_tokens: 0,
574        mode: Some("replace_symbol".to_string()),
575        path: get_str(args, "path"),
576        changed,
577        shell_outcome: None,
578        content_blocks: None,
579    })
580}
581
582/// #879: resolve `find`/`replace` for replace_all, failing *closed* on the
583/// destructive path. Historically a missing `replace` defaulted to "" — so a
584/// typo'd replacement key (`new_string=`/`new_text=` carried over from the other
585/// ops) meant "delete every match" and still reported success. Now: reject
586/// replacement keys that belong to other ops, and require `replace` to be
587/// present. An empty deletion must be opted into explicitly with `replace=""`.
588fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
589    let find = get_str(args, "find")
590        .filter(|s| !s.is_empty())
591        .ok_or("replace_all requires non-empty 'find'")?;
592
593    for foreign in ["new_text", "new_string", "old_string"] {
594        if args.contains_key(foreign) {
595            return Err(format!(
596                "replace_all names its replacement 'replace', not '{foreign}' — rename it \
597                 (an unrecognized replacement key would silently delete every match)"
598            ));
599        }
600    }
601
602    let replace = args
603        .get("replace")
604        .and_then(Value::as_str)
605        .map(String::from)
606        .ok_or(
607            "replace_all requires 'replace' (the replacement text); pass replace=\"\" \
608             explicitly to delete every match",
609        )?;
610
611    Ok((find, replace))
612}
613
614/// #825: Bulk literal find-and-replace — no anchors needed.
615fn handle_replace_all(
616    args: &Map<String, Value>,
617    ctx: &ToolContext,
618) -> Result<ToolOutput, ErrorData> {
619    let path = require_resolved_path(ctx, args, "path")?;
620    let (find, replace) =
621        resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
622    let dry_run = get_bool(args, "dry_run").unwrap_or(false);
623
624    let content = std::fs::read_to_string(&path)
625        .map_err(|e| ErrorData::internal_error(format!("cannot read {path}: {e}"), None))?;
626
627    let count = content.matches(find.as_str()).count();
628    if count == 0 {
629        return Ok(ToolOutput::simple(format!(
630            "No matches for {find:?} in {path}"
631        )));
632    }
633
634    if dry_run {
635        return Ok(ToolOutput::simple(format!(
636            "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
637        )));
638    }
639
640    let file_lock = crate::core::path_locks::per_file_lock(&path);
641    let _guard = file_lock
642        .lock()
643        .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
644
645    let new_content = content.replace(find.as_str(), &replace);
646    crate::config_io::write_atomic(std::path::Path::new(&path), &new_content)
647        .map_err(|e| ErrorData::internal_error(format!("write failed: {e}"), None))?;
648
649    if let Some(cache) = ctx.cache.as_ref() {
650        if let Some(mut c) =
651            crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
652        {
653            c.invalidate(&path);
654        }
655    }
656
657    Ok(ToolOutput::simple(format!(
658        "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
659    )))
660}
661
662#[cfg(test)]
663mod replace_all_tests {
664    use super::*;
665    use serde_json::json;
666
667    fn obj(v: Value) -> Map<String, Value> {
668        match v {
669            Value::Object(m) => m,
670            _ => panic!("expected object"),
671        }
672    }
673
674    #[test]
675    fn resolves_find_and_replace() {
676        let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
677        assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
678    }
679
680    #[test]
681    fn explicit_empty_replace_is_a_deletion() {
682        let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
683        assert_eq!(r, "");
684    }
685
686    #[test]
687    fn missing_replace_is_rejected_not_silent_delete() {
688        let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
689        assert!(err.contains("requires 'replace'"), "got: {err}");
690    }
691
692    #[test]
693    fn foreign_replacement_key_is_rejected() {
694        for key in ["new_string", "new_text", "old_string"] {
695            let err = resolve_find_replace(&obj(json!({"find": "a", key: "b"}))).unwrap_err();
696            assert!(
697                err.contains(key),
698                "must name the offending key {key}: {err}"
699            );
700        }
701    }
702
703    #[test]
704    fn empty_find_is_rejected() {
705        let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
706        assert!(err.contains("find"), "got: {err}");
707    }
708}
709
710#[cfg(test)]
711mod batch_grouping_tests {
712    use super::*;
713    use serde_json::json;
714
715    /// #1005: a batch spanning two files groups ops per file, preserving the
716    /// first-seen file order — no top-level `path` needed.
717    #[test]
718    fn groups_ops_across_files_preserving_order() {
719        let ops = vec![
720            json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
721            json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
722            json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
723        ];
724        let g = group_ops_by_path(&ops, None).unwrap();
725        assert_eq!(g.len(), 2);
726        assert_eq!(g[0].0, "a.go");
727        assert_eq!(g[0].1.len(), 2);
728        assert_eq!(g[1].0, "b.go");
729        assert_eq!(g[1].1.len(), 1);
730    }
731
732    #[test]
733    fn ops_without_path_fall_back_to_top_level() {
734        let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
735        let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
736        assert_eq!(g.len(), 1);
737        assert_eq!(g[0].0, "top.go");
738    }
739
740    #[test]
741    fn op_without_path_and_no_top_level_is_rejected() {
742        let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
743        let err = group_ops_by_path(&ops, None).unwrap_err();
744        assert!(err.contains("path"), "got: {err}");
745    }
746
747    #[test]
748    fn empty_ops_rejected() {
749        let err = group_ops_by_path(&[], None).unwrap_err();
750        assert!(err.contains("empty"), "got: {err}");
751    }
752
753    #[test]
754    fn cross_file_rejects_single_value_preimage_and_backup_options() {
755        for key in ["expected_md5", "backup_path"] {
756            let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
757            assert!(validate_cross_file_options(&args, 2).is_err());
758            assert!(validate_cross_file_options(&args, 1).is_ok());
759        }
760    }
761
762    #[test]
763    fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
764        let args = Map::from_iter([
765            ("path".into(), json!("a.rs")),
766            ("op".into(), json!("replace_unique")),
767            ("old_text".into(), json!("old")),
768            ("new_text".into(), json!("new")),
769        ]);
770        let mapped = build_unique_edit_args(&args).expect("valid mapping");
771        assert_eq!(mapped.get("old_string"), Some(&json!("old")));
772        assert_eq!(mapped.get("new_string"), Some(&json!("new")));
773        assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
774        assert!(!mapped.contains_key("op"));
775        assert!(!mapped.contains_key("old_text"));
776    }
777
778    #[test]
779    fn replace_unique_requires_explicit_old_and_new_text() {
780        assert!(build_unique_edit_args(&Map::new()).is_err());
781        let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
782        assert!(build_unique_edit_args(&only_old).is_err());
783    }
784
785    #[test]
786    fn dry_run_replace_unique_does_not_apply() {
787        let args = Map::from_iter([
788            ("path".into(), json!("a.rs")),
789            ("op".into(), json!("replace_unique")),
790            ("old_text".into(), json!("old")),
791            ("new_text".into(), json!("new")),
792            ("dry_run".into(), json!(true)),
793        ]);
794        let edit_args = build_unique_edit_args(&args).expect("validation passes");
795        assert!(
796            edit_args.contains_key("old_string"),
797            "args mapped correctly"
798        );
799        assert!(
800            args.get("dry_run").and_then(Value::as_bool) == Some(true),
801            "dry_run flag preserved in original args"
802        );
803    }
804}