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