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. A 0-byte file: create, or replace_unique with old_text=\"\".",
58            json!({
59                "type": "object",
60                "properties": {
61                    "path": { "type": "string" },
62                    "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_unique", "replace_symbol", "create", "replace_all"] },
63                    "line": { "type": "integer" },
64                    "hash": { "type": "string" },
65                    "start_line": { "type": "integer" },
66                    "start_hash": { "type": "string" },
67                    "end_line": { "type": "integer" },
68                    "end_hash": { "type": "string" },
69                    "new_text": { "type": "string" },
70                    "old_text": { "type": "string" },
71                    "name": { "type": "string" },
72                    "find": { "type": "string" },
73                    "replace": { "type": "string" },
74                    "dry_run": { "type": "boolean" },
75                    "ops": { "type": "array", "items": { "type": "object" } }
76                },
77                // 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    // #2223: an empty old_text quotes nothing, so it can only fill a file that
521    // holds nothing. Admit it for an existing 0-byte regular file only; ctx_edit
522    // re-checks emptiness under its per-file lock right before the atomic write,
523    // so content that appears in between is refused, never overwritten.
524    if get_str(&edit_args, "old_string").is_some_and(|old| old.is_empty()) {
525        let path = require_resolved_path(ctx, args, "path")?;
526        if let Some(message) = empty_old_text_refusal(std::path::Path::new(&path)) {
527            return Err(ErrorData::invalid_params(message, None));
528        }
529    }
530
531    if get_bool(args, "dry_run").unwrap_or(false) {
532        let old_text = get_str(args, "old_text")
533            .or_else(|| get_str(args, "old_string"))
534            .unwrap_or_default();
535        let new_text = get_str(args, "new_text")
536            .or_else(|| get_str(args, "new_string"))
537            .unwrap_or_default();
538        let path = get_str(args, "path").unwrap_or_default();
539        return Ok(ToolOutput::simple(format!(
540            "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
541        )));
542    }
543
544    crate::tools::registered::ctx_edit::handle_edit(&edit_args, ctx, host_draws_diffs(ctx))
545}
546
547/// Why an empty `old_text` cannot target `path`, naming the op that can; `None`
548/// for an existing empty regular file (a symlink is refused, as `op=create`
549/// refuses it).
550fn empty_old_text_refusal(path: &std::path::Path) -> Option<String> {
551    let shown = path.display();
552    match std::fs::symlink_metadata(path) {
553        Ok(meta) if meta.file_type().is_file() && meta.len() == 0 => None,
554        Ok(meta) if meta.file_type().is_file() => Some(format!(
555            "replace_unique with empty old_text only fills an existing empty (0-byte) file; \
556             {shown} has content. Quote unique text from it as old_text, or use anchored ops \
557             (ctx_read mode=\"anchored\" → insert_after/set_line/replace_lines)"
558        )),
559        Ok(_) => Some(format!(
560            "replace_unique with empty old_text only fills an existing empty regular file; \
561             {shown} is not one (symlink or directory)"
562        )),
563        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(format!(
564            "replace_unique with empty old_text only fills an existing empty file; \
565             {shown} does not exist — use op=create to write a new file"
566        )),
567        Err(error) => Some(format!("cannot inspect {shown}: {error}")),
568    }
569}
570
571fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
572    // An empty old_text is admitted here; `delegate_replace_unique` limits it
573    // to an existing empty file (#2223).
574    let old_text = get_str(args, "old_text")
575        .or_else(|| get_str(args, "old_string"))
576        .ok_or_else(|| {
577            "replace_unique requires old_text (old_string also accepted); an empty \
578             old_text fills an existing empty file"
579                .to_string()
580        })?;
581    let new_text = get_str(args, "new_text")
582        .or_else(|| get_str(args, "new_string"))
583        .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
584
585    let mut edit_args = args.clone();
586    edit_args.insert("old_string".into(), Value::String(old_text));
587    edit_args.insert("new_string".into(), Value::String(new_text));
588    edit_args.insert("replace_all".into(), Value::Bool(false));
589    edit_args.remove("old_text");
590    edit_args.remove("op");
591    // ctx_edit's create=true overwrites a whole file; replace_unique never
592    // means that, so a stray `create` key must not reach it.
593    edit_args.remove("create");
594    Ok(edit_args)
595}
596
597/// Options with one top-level value cannot be applied safely to multiple files:
598/// one digest cannot describe several preimages, and one explicit backup path
599/// would be overwritten by the second file. Reject before any write occurs.
600fn validate_cross_file_options(
601    args: &Map<String, Value>,
602    file_count: usize,
603) -> Result<(), ErrorData> {
604    if file_count <= 1 {
605        return Ok(());
606    }
607    for key in ["expected_blake3", "expected_md5", "backup_path"] {
608        if args.contains_key(key) {
609            return Err(ErrorData::invalid_params(
610                format!("cross-file ctx_patch batches do not support top-level '{key}'"),
611                None,
612            ));
613        }
614    }
615    Ok(())
616}
617
618/// Build the per-file work list. A single op targets the top-level `path`
619/// (still required). A batch (`ops[]`) groups its ops by each op's own `path`,
620/// falling back to the top-level `path`, so a batch may span files without a
621/// top-level `path` (#1005).
622fn plan_groups(
623    args: &Map<String, Value>,
624    ctx: &ToolContext,
625) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
626    let Some(ops_val) = args.get("ops") else {
627        let path = require_resolved_path(ctx, args, "path")?;
628        let ops = crate::tools::ctx_patch::parse_ops(args)
629            .map_err(|e| ErrorData::invalid_params(e, None))?;
630        return Ok(vec![(path, ops)]);
631    };
632
633    let arr = ops_val
634        .as_array()
635        .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
636    let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
637        .map_err(|e| ErrorData::invalid_params(e, None))?;
638
639    let mut groups = Vec::with_capacity(grouped.len());
640    for (raw_path, op_objs) in grouped {
641        let resolved = ctx
642            .resolve_path_sync(&raw_path)
643            .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
644        ctx.ensure_writable(&resolved)
645            .map_err(|e| ErrorData::invalid_params(e, None))?;
646        let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
647        let ops = crate::tools::ctx_patch::parse_ops(&sub)
648            .map_err(|e| ErrorData::invalid_params(e, None))?;
649        groups.push((resolved, ops));
650    }
651    Ok(groups)
652}
653
654/// Pure grouping: bucket batch op objects by their own `path` (or the batch's
655/// top-level `path` fallback), preserving first-seen file order. Errors if an op
656/// names no path and there is no top-level fallback.
657fn group_ops_by_path(
658    ops: &[Value],
659    top_path: Option<&str>,
660) -> Result<Vec<(String, Vec<Value>)>, String> {
661    if ops.is_empty() {
662        return Err("ops[] is empty — provide at least one edit".to_string());
663    }
664    let mut order: Vec<String> = Vec::new();
665    let mut by_path: std::collections::HashMap<String, Vec<Value>> =
666        std::collections::HashMap::new();
667    for (i, op) in ops.iter().enumerate() {
668        let obj = op
669            .as_object()
670            .ok_or_else(|| format!("ops[{i}] must be an object"))?;
671        let raw = obj
672            .get("path")
673            .and_then(Value::as_str)
674            .map(str::to_string)
675            .or_else(|| top_path.map(str::to_string))
676            .ok_or_else(|| {
677                format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
678            })?;
679        if !by_path.contains_key(&raw) {
680            order.push(raw.clone());
681        }
682        by_path.entry(raw).or_default().push(op.clone());
683    }
684    Ok(order
685        .into_iter()
686        .map(|p| {
687            let ops = by_path.remove(&p).unwrap_or_default();
688            (p, ops)
689        })
690        .collect())
691}
692
693/// Apply one file's anchored patch: acquire the per-file lock, run the I/O off
694/// the global cache lock, then fold the resulting cache effect and session mark.
695/// Returns the rendered patch/CONFLICT text. Shared by the single-op and the
696/// per-file batch paths (#1005).
697fn apply_one(
698    ctx: &ToolContext,
699    params: &crate::tools::ctx_patch::PatchParams,
700) -> Result<String, ErrorData> {
701    let path = params.path.clone();
702    {
703        let cache_lock = ctx
704            .cache
705            .as_ref()
706            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
707
708        // Serialize edits to the SAME file via the shared per-file lock (the same
709        // registry ctx_edit/ctx_read use), so anchored and str_replace edits of
710        // one file never interleave (issue #320). Correctness across processes
711        // still rests on the TOCTOU preimage guard + atomic rename.
712        let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
713        let _file_guard = {
714            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
715            loop {
716                if let Ok(guard) = file_lock.try_lock() {
717                    break guard;
718                }
719                if std::time::Instant::now() >= deadline {
720                    return Err(ErrorData::internal_error(
721                        format!(
722                            "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
723                        ),
724                        None,
725                    ));
726                }
727                std::thread::sleep(std::time::Duration::from_millis(20));
728            }
729        };
730
731        let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
732        {
733            Some(cache) => cache
734                .get(&path)
735                .map(|e| e.last_mode.clone())
736                .unwrap_or_default(),
737            None => String::new(),
738        };
739
740        // Heavy disk I/O — no global cache lock held here.
741        let before = std::fs::read(&path).unwrap_or_default();
742        let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
743        let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
744
745        if written {
746            let after = std::fs::read(&path).unwrap_or_default();
747            super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &before, &after);
748        }
749
750        crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
751
752        if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
753            crate::tools::ctx_read::dedup_hook::on_write(&path);
754            match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
755                Some(mut cache) => {
756                    crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
757                }
758                None => {
759                    tracing::warn!(
760                        "ctx_patch: cache write-lock timeout applying effect for {path}"
761                    );
762                }
763            }
764        }
765
766        if !written {
767            return Err(tool_execution_error(output));
768        }
769
770        if let Some(session_lock) = ctx.session.as_ref() {
771            if let Some(mut session) =
772                crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
773            {
774                session.mark_modified(&path);
775            }
776        }
777
778        Ok(output)
779    }
780}
781
782/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
783/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
784/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
785/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
786fn delegate_replace_symbol(
787    args: &Map<String, Value>,
788    ctx: &ToolContext,
789) -> Result<ToolOutput, ErrorData> {
790    let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
791        .map_err(|e| ErrorData::invalid_params(e, None))?;
792
793    if get_bool(args, "dry_run").unwrap_or(false) {
794        let name = get_str(args, "name").unwrap_or_default();
795        let path = get_str(args, "path").unwrap_or_default();
796        return Ok(ToolOutput::simple(format!(
797            "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
798        )));
799    }
800
801    // Resolve `path` at the boundary when given (the name route resolves its own
802    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
803    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
804    let has_path = args.get("path").and_then(Value::as_str).is_some();
805    let abs_path = if has_path {
806        require_resolved_path(ctx, args, "path")?
807    } else {
808        String::new()
809    };
810
811    let args_value = Value::Object(refactor_args);
812    let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
813    let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
814    if !changed {
815        return Err(tool_execution_error(result));
816    }
817
818    Ok(ToolOutput {
819        text: result,
820        original_tokens: 0,
821        saved_tokens: 0,
822        mode: Some("replace_symbol".to_string()),
823        path: get_str(args, "path"),
824        changed,
825        shell_outcome: None,
826        content_blocks: None,
827    })
828}
829
830/// #879: resolve `find`/`replace` for replace_all, failing *closed* on the
831/// destructive path. Historically a missing `replace` defaulted to "" — so a
832/// typo'd replacement key (`new_string=`/`new_text=` carried over from the other
833/// ops) meant "delete every match" and still reported success. Now: reject
834/// replacement keys that belong to other ops, and require `replace` to be
835/// present. An empty deletion must be opted into explicitly with `replace=""`.
836///
837/// The other ops' names are accepted as aliases — `old_string`/`old_text` for
838/// `find`, `new_string`/`new_text` for `replace` — because agents carry them
839/// over and the mapping is unambiguous. What stays closed is the #879 hole: a
840/// replacement must still be *named*, so a missing one is never read as "".
841fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
842    let find = aliased(args, "find", &["old_string", "old_text"])?
843        .filter(|s| !s.is_empty())
844        .ok_or("replace_all requires non-empty 'find' (old_string also accepted)")?;
845    let replace = aliased(args, "replace", &["new_string", "new_text"])?.ok_or(
846        "replace_all requires 'replace' (the replacement text, new_string also accepted); \
847         pass replace=\"\" explicitly to delete every match",
848    )?;
849    Ok((find, replace))
850}
851
852/// The string under `key` or one of its `aliases`. Two spellings that
853/// disagree are an error rather than a silent pick.
854fn aliased(
855    args: &Map<String, Value>,
856    key: &str,
857    aliases: &[&str],
858) -> Result<Option<String>, String> {
859    let mut found: Option<(&str, &str)> = None;
860    for name in std::iter::once(key).chain(aliases.iter().copied()) {
861        let Some(value) = args.get(name).and_then(Value::as_str) else {
862            continue;
863        };
864        match found {
865            Some((first, seen)) if seen != value => {
866                return Err(format!(
867                    "'{first}' and '{name}' name the same field with different values — pass only one"
868                ));
869            }
870            Some(_) => {}
871            None => found = Some((name, value)),
872        }
873    }
874    Ok(found.map(|(_, value)| value.to_string()))
875}
876
877/// What a bulk replace works from: the ORIGINAL text (the only thing that is
878/// ever written back, with the replacement applied) and the match count the
879/// caller is told.
880///
881/// The count is model-visible and is a statement about the file's content, so
882/// it is taken from the ADMITTED text, read through the common rooted reader
883/// (G01). A source that is withheld is neither counted nor edited. When the
884/// admitted text and the original disagree about the matches, the pattern
885/// touches content the read path masks: answering with the original's count
886/// would confirm a guess of a masked value, and replacing would edit content
887/// the caller cannot see — the operation is refused and nothing is written.
888/// Admitted or redacted text is never written to disk.
889///
890/// The original is read through the shared edit boundary (`edit_io`: symlink
891/// rejection, size cap, strict UTF-8), and its fingerprint is what the write
892/// is later checked against.
893fn replace_all_plan(
894    path: &str,
895    root: &str,
896    find: &str,
897) -> Result<(crate::tools::edit_io::FilePreimage, usize), String> {
898    use crate::tools::admitted_sources::SourceRead;
899    let cap = lean_ctx::core::limits::max_read_bytes();
900    let original = crate::tools::edit_io::read_preimage(std::path::Path::new(path), cap, false)?;
901    let mut remaining = cap;
902    let admitted =
903        match crate::tools::admitted_sources::read(path, root, "ctx_patch", &mut remaining) {
904            SourceRead::Admitted(read) => read.content,
905            SourceRead::Withheld(reason) | SourceRead::Unavailable(reason) => {
906                return Err(format!("cannot read {path}: {reason}"));
907            }
908        };
909    let count = admitted.matches(find).count();
910    if count != original.text.matches(find).count() {
911        return Err(format!(
912            "replace_all refused for {path}: the pattern overlaps content the context gateway \
913             masks; nothing was changed"
914        ));
915    }
916    Ok((original, count))
917}
918
919/// Writes a bulk replace through the shared edit boundary: the file must still
920/// match the fingerprint it was planned from (TOCTOU guard), and the write
921/// rejects symlinks and read-only roots and keeps the file's permissions.
922fn replace_all_write(
923    path: &str,
924    pre: &crate::tools::edit_io::FilePreimage,
925    new_content: &str,
926) -> Result<(), String> {
927    let path = std::path::Path::new(path);
928    crate::tools::edit_io::ensure_preimage_still_matches(
929        path,
930        &pre.fp,
931        lean_ctx::core::limits::max_read_bytes(),
932    )?;
933    crate::tools::edit_io::write_atomic_bytes_with_permissions(
934        path,
935        new_content.as_bytes(),
936        Some(&pre.permissions),
937    )
938}
939
940/// #825: Bulk literal find-and-replace — no anchors needed.
941fn handle_replace_all(
942    args: &Map<String, Value>,
943    ctx: &ToolContext,
944) -> Result<ToolOutput, ErrorData> {
945    let path = require_resolved_path(ctx, args, "path")?;
946    let (find, replace) =
947        resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
948    let dry_run = get_bool(args, "dry_run").unwrap_or(false);
949
950    // The per-file lock is held from the read through the write, so another
951    // edit of this file in this process cannot land between them.
952    let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
953    let _guard = file_lock
954        .lock()
955        .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
956
957    let root = crate::tools::admitted_sources::root_in_context(&path, ctx);
958    let (pre, count) = replace_all_plan(&path, &root, &find).map_err(tool_execution_error)?;
959    if count == 0 {
960        return Ok(ToolOutput::simple(format!(
961            "No matches for {find:?} in {path}"
962        )));
963    }
964
965    if dry_run {
966        return Ok(ToolOutput::simple(format!(
967            "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
968        )));
969    }
970
971    let new_content = pre.text.replace(find.as_str(), &replace);
972    replace_all_write(&path, &pre, &new_content).map_err(tool_execution_error)?;
973    super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &pre.bytes, new_content.as_bytes());
974
975    crate::tools::ctx_read::dedup_hook::on_write(&path);
976    if let Some(cache) = ctx.cache.as_ref() {
977        if let Some(mut c) =
978            crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
979        {
980            c.invalidate(&path);
981        }
982    }
983    if let Some(session_lock) = ctx.session.as_ref() {
984        if let Some(mut session) =
985            crate::server::bounded_lock::write(session_lock, "ctx_patch replace_all session")
986        {
987            session.mark_modified(&path);
988        }
989    }
990
991    Ok(ToolOutput::simple(format!(
992        "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
993    )))
994}
995
996#[cfg(test)]
997mod replace_all_tests {
998    use super::*;
999    use serde_json::json;
1000
1001    fn obj(v: Value) -> Map<String, Value> {
1002        match v {
1003            Value::Object(m) => m,
1004            _ => panic!("expected object"),
1005        }
1006    }
1007
1008    #[test]
1009    fn resolves_find_and_replace() {
1010        let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
1011        assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
1012    }
1013
1014    #[test]
1015    fn explicit_empty_replace_is_a_deletion() {
1016        let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
1017        assert_eq!(r, "");
1018    }
1019
1020    #[test]
1021    fn missing_replace_is_rejected_not_silent_delete() {
1022        let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
1023        assert!(err.contains("requires 'replace'"), "got: {err}");
1024    }
1025
1026    #[test]
1027    fn other_ops_names_are_aliases() {
1028        for (old, new) in [("old_string", "new_string"), ("old_text", "new_text")] {
1029            let (f, r) = resolve_find_replace(&obj(json!({old: "a", new: "b"}))).unwrap();
1030            assert_eq!((f.as_str(), r.as_str()), ("a", "b"), "{old}/{new}");
1031        }
1032    }
1033
1034    #[test]
1035    fn alias_never_turns_a_missing_replacement_into_a_delete() {
1036        let err = resolve_find_replace(&obj(json!({"old_string": "a"}))).unwrap_err();
1037        assert!(err.contains("requires 'replace'"), "got: {err}");
1038    }
1039
1040    #[test]
1041    fn conflicting_spellings_are_rejected() {
1042        let err = resolve_find_replace(&obj(
1043            json!({"find": "a", "old_string": "x", "replace": "b"}),
1044        ))
1045        .unwrap_err();
1046        assert!(
1047            err.contains("find") && err.contains("old_string"),
1048            "got: {err}"
1049        );
1050        let same = resolve_find_replace(&obj(
1051            json!({"find": "a", "old_string": "a", "replace": "b"}),
1052        ));
1053        assert!(same.is_ok(), "identical spellings agree");
1054    }
1055
1056    #[test]
1057    fn empty_find_is_rejected() {
1058        let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
1059        assert!(err.contains("find"), "got: {err}");
1060    }
1061
1062    /// G01: `replace_all` read the file with `fs::read_to_string` and reported
1063    /// how often the pattern occurs — for any file, also one the request may
1064    /// not read, and also for a value the read path masks. The count now
1065    /// comes from the admitted text; the text that is written stays the
1066    /// original.
1067    #[test]
1068    fn the_reported_count_comes_from_the_admitted_text_and_the_original_is_what_is_written() {
1069        use lean_ctx::core::policy::runtime::TestPolicyOverride;
1070        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1071        let policy = lean_ctx::core::policy::load(
1072            "name = \"ctx-patch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
1073             [redaction]\ncustomer = \"CUS-[0-9]{4}\"\n\
1074             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
1075        )
1076        .unwrap();
1077        let project = tempfile::tempdir().unwrap();
1078        let root = project.path().to_string_lossy().into_owned();
1079        let file = |name: &str, content: &str| {
1080            let path = project.path().join(name);
1081            std::fs::write(&path, content).unwrap();
1082            path.to_string_lossy().into_owned()
1083        };
1084        let original = "let owner = \"CUS-1234\";\nlet mode = old_mode();\nold_mode();\n";
1085        let orders = file("orders.rs", original);
1086        let plan = file("plan.rs", "CONFIDENTIAL\nold_mode();\n");
1087        let planned = |path: &str, find: &str| {
1088            replace_all_plan(path, &root, find).map(|(pre, count)| (pre.text, count))
1089        };
1090        {
1091            // Control without a pack: original and count as before.
1092            let _community = TestPolicyOverride::set(None);
1093            assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1094        }
1095        let _policy = TestPolicyOverride::set(Some(policy));
1096        // Unmasked content: the ORIGINAL is returned for the write.
1097        assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1098        // A guess of the masked value is neither confirmed nor replaced.
1099        let refused = replace_all_plan(&orders, &root, "CUS-1234").unwrap_err();
1100        assert!(refused.contains("refused"), "{refused}");
1101        assert!(!refused.contains("1 occurrence"), "{refused}");
1102        // A withheld source is not counted at all.
1103        let withheld = replace_all_plan(&plan, &root, "old_mode").unwrap_err();
1104        assert!(withheld.contains("withheld"), "{withheld}");
1105        // No project root: nothing is read.
1106        assert!(replace_all_plan(&orders, "", "old_mode").is_err());
1107    }
1108
1109    /// F4: replace_all reads and writes through the shared edit boundary, so a
1110    /// symlink is refused on the read and a file that changed after planning
1111    /// is not overwritten (TOCTOU guard).
1112    #[test]
1113    fn replace_all_uses_the_edit_boundary() {
1114        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1115        let project = tempfile::tempdir().unwrap();
1116        let root = project.path().to_string_lossy().into_owned();
1117        let target = project.path().join("a.rs");
1118        std::fs::write(&target, "old();\nold();\n").unwrap();
1119        let target_str = target.to_string_lossy().into_owned();
1120
1121        #[cfg(unix)]
1122        {
1123            let link = project.path().join("link.rs");
1124            std::os::unix::fs::symlink(&target, &link).unwrap();
1125            let err = replace_all_plan(&link.to_string_lossy(), &root, "old").unwrap_err();
1126            assert!(err.contains("symlink"), "{err}");
1127        }
1128
1129        let (pre, count) = replace_all_plan(&target_str, &root, "old").unwrap();
1130        assert_eq!(count, 2);
1131        // A concurrent writer changes the file after the plan was read.
1132        std::fs::write(&target, "old();\nnewer();\n").unwrap();
1133        let new_content = pre.text.replace("old", "new");
1134        let err = replace_all_write(&target_str, &pre, &new_content).unwrap_err();
1135        assert!(err.contains("TOCTOU"), "{err}");
1136        assert_eq!(
1137            std::fs::read_to_string(&target).unwrap(),
1138            "old();\nnewer();\n"
1139        );
1140
1141        // Unchanged since the plan: the write lands.
1142        let (pre, _) = replace_all_plan(&target_str, &root, "old").unwrap();
1143        let new_content = pre.text.replace("old", "new");
1144        replace_all_write(&target_str, &pre, &new_content).unwrap();
1145        assert_eq!(
1146            std::fs::read_to_string(&target).unwrap(),
1147            "new();\nnewer();\n"
1148        );
1149    }
1150}
1151
1152#[cfg(test)]
1153mod batch_grouping_tests {
1154    use super::*;
1155    use serde_json::json;
1156
1157    /// #1005: a batch spanning two files groups ops per file, preserving the
1158    /// first-seen file order — no top-level `path` needed.
1159    #[test]
1160    fn groups_ops_across_files_preserving_order() {
1161        let ops = vec![
1162            json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
1163            json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
1164            json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
1165        ];
1166        let g = group_ops_by_path(&ops, None).unwrap();
1167        assert_eq!(g.len(), 2);
1168        assert_eq!(g[0].0, "a.go");
1169        assert_eq!(g[0].1.len(), 2);
1170        assert_eq!(g[1].0, "b.go");
1171        assert_eq!(g[1].1.len(), 1);
1172    }
1173
1174    #[test]
1175    fn ops_without_path_fall_back_to_top_level() {
1176        let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1177        let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
1178        assert_eq!(g.len(), 1);
1179        assert_eq!(g[0].0, "top.go");
1180    }
1181
1182    #[test]
1183    fn op_without_path_and_no_top_level_is_rejected() {
1184        let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1185        let err = group_ops_by_path(&ops, None).unwrap_err();
1186        assert!(err.contains("path"), "got: {err}");
1187    }
1188
1189    #[test]
1190    fn empty_ops_rejected() {
1191        let err = group_ops_by_path(&[], None).unwrap_err();
1192        assert!(err.contains("empty"), "got: {err}");
1193    }
1194
1195    #[test]
1196    fn cross_file_rejects_single_value_preimage_and_backup_options() {
1197        for key in ["expected_blake3", "expected_md5", "backup_path"] {
1198            let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
1199            assert!(validate_cross_file_options(&args, 2).is_err());
1200            assert!(validate_cross_file_options(&args, 1).is_ok());
1201        }
1202    }
1203
1204    #[test]
1205    fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
1206        let args = Map::from_iter([
1207            ("path".into(), json!("a.rs")),
1208            ("op".into(), json!("replace_unique")),
1209            ("old_text".into(), json!("old")),
1210            ("new_text".into(), json!("new")),
1211        ]);
1212        let mapped = build_unique_edit_args(&args).expect("valid mapping");
1213        assert_eq!(mapped.get("old_string"), Some(&json!("old")));
1214        assert_eq!(mapped.get("new_string"), Some(&json!("new")));
1215        assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
1216        assert!(!mapped.contains_key("op"));
1217        assert!(!mapped.contains_key("old_text"));
1218    }
1219
1220    #[test]
1221    fn replace_unique_requires_explicit_old_and_new_text() {
1222        assert!(build_unique_edit_args(&Map::new()).is_err());
1223        let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
1224        assert!(build_unique_edit_args(&only_old).is_err());
1225    }
1226
1227    #[test]
1228    fn dry_run_replace_unique_does_not_apply() {
1229        let args = Map::from_iter([
1230            ("path".into(), json!("a.rs")),
1231            ("op".into(), json!("replace_unique")),
1232            ("old_text".into(), json!("old")),
1233            ("new_text".into(), json!("new")),
1234            ("dry_run".into(), json!(true)),
1235        ]);
1236        let edit_args = build_unique_edit_args(&args).expect("validation passes");
1237        assert!(
1238            edit_args.contains_key("old_string"),
1239            "args mapped correctly"
1240        );
1241        assert!(
1242            args.get("dry_run").and_then(Value::as_bool) == Some(true),
1243            "dry_run flag preserved in original args"
1244        );
1245    }
1246
1247    #[test]
1248    fn text_replacement_without_op_is_inferred_as_replace_unique() {
1249        let top = Map::from_iter([
1250            ("path".into(), json!("a.rs")),
1251            ("old_string".into(), json!("old")),
1252            ("new_string".into(), json!("new")),
1253        ]);
1254        let inferred = infer_replace_unique(&top).expect("op inferred");
1255        assert_eq!(inferred.get("op"), Some(&json!("replace_unique")));
1256
1257        let batch = Map::from_iter([(
1258            "ops".into(),
1259            json!([
1260                {"path": "a.rs", "old_text": "a", "new_text": "b"},
1261                {"op": "set_line", "line": 1, "hash": "aa", "new_text": "x"}
1262            ]),
1263        )]);
1264        let inferred = infer_replace_unique(&batch).expect("op inferred in ops[]");
1265        assert_eq!(inferred["ops"][0]["op"], json!("replace_unique"));
1266        assert_eq!(inferred["ops"][1]["op"], json!("set_line"));
1267
1268        // An explicit op, an empty old text or a missing new text stay as sent.
1269        for unchanged in [
1270            json!({"op": "set_line", "old_text": "a", "new_text": "b"}),
1271            json!({"old_text": "", "new_text": "b"}),
1272            json!({"old_text": "a"}),
1273        ] {
1274            assert!(infer_replace_unique(unchanged.as_object().unwrap()).is_none());
1275        }
1276
1277        // The shapes behind every ctx_patch error of the pilot benchmark.
1278        let encoded = json!({"ops": "[{\"op\":\"replace_unique\",\"path\":\"a.rs\",\"old_text\":\"a\",\"new_text\":\"b\"}]"});
1279        let repaired = infer_replace_unique(encoded.as_object().unwrap()).expect("ops decoded");
1280        assert_eq!(repaired["ops"][0]["old_text"], json!("a"));
1281        let create = json!({"op": "create", "path": "n.py", "new_string": "x = 1\n"});
1282        let repaired = infer_replace_unique(create.as_object().unwrap()).expect("create aliased");
1283        assert_eq!(repaired["new_text"], json!("x = 1\n"));
1284        let find_replace =
1285            json!({"op": "replace_unique", "path": "a.rs", "find": "a", "replace": "b"});
1286        let repaired = infer_replace_unique(find_replace.as_object().unwrap()).expect("aliased");
1287        assert_eq!(
1288            (&repaired["old_text"], &repaired["new_text"]),
1289            (&json!("a"), &json!("b"))
1290        );
1291        // replace_all keeps its own find/replace keys.
1292        let replace_all = json!({"op": "replace_all", "path": "a.rs", "find": "a", "replace": "b"});
1293        assert!(infer_replace_unique(replace_all.as_object().unwrap()).is_none());
1294    }
1295
1296    /// Runs `replace_unique` with an empty old_text against `path` through the
1297    /// full tool handler (PathJail-resolved path, ctx_edit write path).
1298    fn empty_old_text_patch(
1299        root: &std::path::Path,
1300        path: &std::path::Path,
1301    ) -> Result<String, ErrorData> {
1302        let resolved = path.to_string_lossy().into_owned();
1303        let ctx = ToolContext {
1304            project_root: root.to_string_lossy().into_owned(),
1305            resolved_paths: std::collections::HashMap::from([("path".into(), resolved.clone())]),
1306            cache: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
1307                lean_ctx::core::cache::SessionCache::new(),
1308            ))),
1309            ..ToolContext::default()
1310        };
1311        let args = json!({
1312            "op": "replace_unique", "path": resolved, "old_text": "", "new_text": "filled\n"
1313        });
1314        tokio::task::block_in_place(|| CtxPatchTool.handle(args.as_object().unwrap(), &ctx))
1315            .map(|out| out.text)
1316    }
1317
1318    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1319    async fn replace_unique_with_empty_old_text_fills_an_empty_file() {
1320        // #2223: a 0-byte file had no satisfiable replace_unique input.
1321        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1322        let dir = tempfile::tempdir().unwrap();
1323        let file = dir.path().join("empty.txt");
1324        std::fs::write(&file, "").unwrap();
1325        let text = empty_old_text_patch(dir.path(), &file).expect("empty file is filled");
1326        assert!(text.contains("created empty.txt"), "{text}");
1327        assert_eq!(std::fs::read_to_string(&file).unwrap(), "filled\n");
1328    }
1329
1330    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1331    async fn replace_unique_with_empty_old_text_refuses_other_targets() {
1332        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1333        let dir = tempfile::tempdir().unwrap();
1334
1335        let full = dir.path().join("full.txt");
1336        std::fs::write(&full, "keep\n").unwrap();
1337        let err = empty_old_text_patch(dir.path(), &full).unwrap_err();
1338        assert!(err.message.contains("anchored"), "{}", err.message);
1339        assert_eq!(std::fs::read_to_string(&full).unwrap(), "keep\n");
1340
1341        let missing = dir.path().join("missing.txt");
1342        let err = empty_old_text_patch(dir.path(), &missing).unwrap_err();
1343        assert!(err.message.contains("op=create"), "{}", err.message);
1344        assert!(!missing.exists(), "replace_unique must not create files");
1345
1346        #[cfg(unix)]
1347        {
1348            let target = dir.path().join("target.txt");
1349            std::fs::write(&target, "").unwrap();
1350            let link = dir.path().join("link.txt");
1351            std::os::unix::fs::symlink(&target, &link).unwrap();
1352            let err = empty_old_text_patch(dir.path(), &link).unwrap_err();
1353            assert!(err.message.contains("not one"), "{}", err.message);
1354            assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
1355        }
1356    }
1357
1358    #[test]
1359    fn replace_unique_never_forwards_a_whole_file_create() {
1360        let args = Map::from_iter([
1361            ("old_text".into(), json!("old")),
1362            ("new_text".into(), json!("new")),
1363            ("create".into(), json!(true)),
1364        ]);
1365        let mapped = build_unique_edit_args(&args).expect("valid mapping");
1366        assert!(!mapped.contains_key("create"));
1367    }
1368}