Skip to main content

lean_ctx_server/tools/registered/
ctx_patch.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
14/// Whether this host draws edit diffs from the archive: Claude Code with the
15/// lean-ctx mod in its plugin cache (file-only probe, once per process). Any
16/// other host gets the bare one-line receipt.
17pub(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    // Schema diet (#576 pattern): the advertised surface carries only the
45    // functional teaching (anchor source, op routing, batch atomicity).
46    // Handler-only params stay supported but unadvertised: expected_blake3 (alias
47    // expected_md5),
48    // backup, backup_path, validate_syntax, evidence, diff_max_lines,
49    // allow_lossy_utf8 — same hidden-params contract as ctx_edit.
50    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                // Per-op required params encoded as the source of truth (#1020):
78                // a client reading the schema knows which fields an op needs
79                // BEFORE calling. Each `if` requires `op` so it stays dormant for
80                // batch calls (which carry `op` inside ops[], not at top level).
81                // Only ops with UNCONDITIONAL requirements are listed — insert_after
82                // (hash optional when line=0) and delete (single vs range) keep their
83                // requirements in the parser to avoid rejecting valid calls.
84                "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        // replace_symbol is a whole-symbol rewrite — delegate to the LSP/IDE-aware
111        // ctx_refactor so there is one symbol-edit implementation (epic #1008).
112        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        // #825: replace_all short-circuits before anchor parsing.
121        if get_str(args, "op").as_deref() == Some("replace_all") {
122            return handle_replace_all(args, ctx);
123        }
124
125        // #1088: replace_unique/replace_symbol are allowed inside ops[]. Split
126        // the batch into runs: each delegated op reuses its single-op write
127        // path; consecutive anchored ops keep shared-preimage batch semantics.
128        if let Some(arr) = args.get("ops").and_then(Value::as_array) {
129            // GH #1433: pre-validate before any ops are applied.
130            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
150/// Anchored pipeline: a single anchored op or an ops[] batch of anchored edits
151/// (grouped per file, batch-atomic per file). Split out of `handle` so the
152/// mixed-batch path (#1088) can reuse it per anchored run.
153fn 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    // #1592: the guard is a BLAKE3 digest; `expected_md5` was a misnomer and
162    // stays accepted so existing callers keep working.
163    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    // The diff and digests are opt-in: the default receipt is one line.
172    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    // #1005: a batch (`ops[]`) groups its ops by each op's own `path`, so a
180    // batch may span files and needs no top-level `path`. A single op still
181    // requires the top-level `path`.
182    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    // A one-file receipt already names its file; only a cross-file batch
186    // labels each part with its full path.
187    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
225/// Repairs the argument shapes models send most often instead of rejecting
226/// the call (the pilot's ctx_patch errors were all shape errors, not stale
227/// anchors): `ops` sent as a JSON-encoded string, the text keys of the other
228/// edit tools (`new_string`/`content` for `create` and anchored ops,
229/// `find`/`replace` for `replace_unique`), and an edit that carries old and
230/// new text but no `op`, which can only be `replace_unique` (every other op
231/// needs line numbers or a symbol). Only key names and the array encoding
232/// change; paths are still resolved per op downstream. `None` when nothing
233/// needed repairing.
234fn 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    /// Copies the first present alias into `key` when `key` is absent.
242    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
293/// `Some(op)` when a batch op must be dispatched through its own single-op
294/// write path rather than the anchored pipeline (#1088).
295fn 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
301/// #1088: an ops[] batch containing replace_unique/replace_symbol. Ops apply
302/// strictly in the order given, as if issued as separate calls: consecutive
303/// anchored ops flush as one shared-preimage batch; each delegated op is
304/// dispatched through the same delegate as its top-level form, evaluated
305/// against the file state left by the preceding ops.
306/// Ops that require their own top-level ctx_patch call and cannot be
307/// batched inside ops[].
308fn 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    // GH #1433: pre-validate the entire batch before applying any op.
320    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    // Every receipt already names its file ("patched cli.py: …"); a label is
339    // only needed to tell files apart, so a one-file batch carries none.
340    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
395/// The label of one file's receipt in a cross-file batch: the path relative to
396/// the project root (the spelling the model can send back), or the full path
397/// for a file outside it. The root is tried as given and canonicalized, since
398/// the resolved path may carry the canonical spelling (`/private/var` on macOS,
399/// the long name without `\\?\` on Windows, where paths compare case-insensitively).
400fn 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
429/// Flush buffered anchored ops through the anchored pipeline as one batch.
430fn 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
449/// A cross-file or mixed batch is not a transaction across completed runs.
450/// Preserve their receipts when a later edit fails, and stop before more writes.
451fn 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
464/// Build the (args, ctx) for one delegated batch op: the op object inherits the
465/// batch's top-level `path`/`dry_run` when it doesn't set its own, and the op's
466/// path is resolved into a per-op ctx — the dispatch layer only pre-resolves
467/// the top-level `path`, which may differ or be absent (#1088).
468fn 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        // replace_symbol's `name` route resolves its own path inside
498        // ctx_refactor; replace_unique always needs one.
499        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
510/// One-shot content-anchored edit (#1010). Delegate to ctx_edit's audited
511/// unique-replacement path so ambiguity checks, TOCTOU guards, atomic writes,
512/// cache invalidation, evidence and session tracking remain single-sourced.
513fn 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
556/// Options with one top-level value cannot be applied safely to multiple files:
557/// one digest cannot describe several preimages, and one explicit backup path
558/// would be overwritten by the second file. Reject before any write occurs.
559fn 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
577/// Build the per-file work list. A single op targets the top-level `path`
578/// (still required). A batch (`ops[]`) groups its ops by each op's own `path`,
579/// falling back to the top-level `path`, so a batch may span files without a
580/// top-level `path` (#1005).
581fn 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
613/// Pure grouping: bucket batch op objects by their own `path` (or the batch's
614/// top-level `path` fallback), preserving first-seen file order. Errors if an op
615/// names no path and there is no top-level fallback.
616fn 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
652/// Apply one file's anchored patch: acquire the per-file lock, run the I/O off
653/// the global cache lock, then fold the resulting cache effect and session mark.
654/// Returns the rendered patch/CONFLICT text. Shared by the single-op and the
655/// per-file batch paths (#1005).
656fn 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        // Serialize edits to the SAME file via the shared per-file lock (the same
668        // registry ctx_edit/ctx_read use), so anchored and str_replace edits of
669        // one file never interleave (issue #320). Correctness across processes
670        // still rests on the TOCTOU preimage guard + atomic rename.
671        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        // Heavy disk I/O — no global cache lock held here.
700        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
741/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
742/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
743/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
744/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
745fn 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    // Resolve `path` at the boundary when given (the name route resolves its own
761    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
762    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
763    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
789/// #879: resolve `find`/`replace` for replace_all, failing *closed* on the
790/// destructive path. Historically a missing `replace` defaulted to "" — so a
791/// typo'd replacement key (`new_string=`/`new_text=` carried over from the other
792/// ops) meant "delete every match" and still reported success. Now: reject
793/// replacement keys that belong to other ops, and require `replace` to be
794/// present. An empty deletion must be opted into explicitly with `replace=""`.
795///
796/// The other ops' names are accepted as aliases — `old_string`/`old_text` for
797/// `find`, `new_string`/`new_text` for `replace` — because agents carry them
798/// over and the mapping is unambiguous. What stays closed is the #879 hole: a
799/// replacement must still be *named*, so a missing one is never read as "".
800fn 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
811/// The string under `key` or one of its `aliases`. Two spellings that
812/// disagree are an error rather than a silent pick.
813fn 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
836/// What a bulk replace works from: the ORIGINAL text (the only thing that is
837/// ever written back, with the replacement applied) and the match count the
838/// caller is told.
839///
840/// The count is model-visible and is a statement about the file's content, so
841/// it is taken from the ADMITTED text, read through the common rooted reader
842/// (G01). A source that is withheld is neither counted nor edited. When the
843/// admitted text and the original disagree about the matches, the pattern
844/// touches content the read path masks: answering with the original's count
845/// would confirm a guess of a masked value, and replacing would edit content
846/// the caller cannot see — the operation is refused and nothing is written.
847/// Admitted or redacted text is never written to disk.
848///
849/// The original is read through the shared edit boundary (`edit_io`: symlink
850/// rejection, size cap, strict UTF-8), and its fingerprint is what the write
851/// is later checked against.
852fn 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
878/// Writes a bulk replace through the shared edit boundary: the file must still
879/// match the fingerprint it was planned from (TOCTOU guard), and the write
880/// rejects symlinks and read-only roots and keeps the file's permissions.
881fn 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
899/// #825: Bulk literal find-and-replace — no anchors needed.
900fn 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    // The per-file lock is held from the read through the write, so another
910    // edit of this file in this process cannot land between them.
911    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    /// G01: `replace_all` read the file with `fs::read_to_string` and reported
1022    /// how often the pattern occurs — for any file, also one the request may
1023    /// not read, and also for a value the read path masks. The count now
1024    /// comes from the admitted text; the text that is written stays the
1025    /// original.
1026    #[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            // Control without a pack: original and count as before.
1051            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        // Unmasked content: the ORIGINAL is returned for the write.
1056        assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1057        // A guess of the masked value is neither confirmed nor replaced.
1058        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        // A withheld source is not counted at all.
1062        let withheld = replace_all_plan(&plan, &root, "old_mode").unwrap_err();
1063        assert!(withheld.contains("withheld"), "{withheld}");
1064        // No project root: nothing is read.
1065        assert!(replace_all_plan(&orders, "", "old_mode").is_err());
1066    }
1067
1068    /// F4: replace_all reads and writes through the shared edit boundary, so a
1069    /// symlink is refused on the read and a file that changed after planning
1070    /// is not overwritten (TOCTOU guard).
1071    #[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        // A concurrent writer changes the file after the plan was read.
1091        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        // Unchanged since the plan: the write lands.
1101        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    /// #1005: a batch spanning two files groups ops per file, preserving the
1117    /// first-seen file order — no top-level `path` needed.
1118    #[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        // An explicit op, an empty old text or a missing new text stay as sent.
1228        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        // The shapes behind every ctx_patch error of the pilot benchmark.
1237        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        // replace_all keeps its own find/replace keys.
1251        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}