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        // Unit claims (A-claims): every write below checks other agents'
108        // claims on the units it touches and claims them for this agent.
109        crate::tools::claims_gate::scoped(ctx, || handle_patch(args, ctx))
110    }
111}
112
113fn handle_patch(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
114    let inferred = infer_replace_unique(args);
115    let args = inferred.as_ref().unwrap_or(args);
116
117    // replace_symbol is a whole-symbol rewrite — delegate to the LSP/IDE-aware
118    // ctx_refactor so there is one symbol-edit implementation (epic #1008).
119    if crate::tools::ctx_patch::is_replace_symbol(args) {
120        return delegate_replace_symbol(args, ctx);
121    }
122
123    if get_str(args, "op").as_deref() == Some("replace_unique") {
124        return delegate_replace_unique(args, ctx);
125    }
126
127    // #825: replace_all short-circuits before anchor parsing.
128    if get_str(args, "op").as_deref() == Some("replace_all") {
129        return handle_replace_all(args, ctx);
130    }
131
132    // #1088: replace_unique/replace_symbol are allowed inside ops[]. Split
133    // the batch into runs: each delegated op reuses its single-op write
134    // path; consecutive anchored ops keep shared-preimage batch semantics.
135    if let Some(arr) = args.get("ops").and_then(Value::as_array) {
136        // GH #1433: pre-validate before any ops are applied.
137        for (i, v) in arr.iter().enumerate() {
138            if let Some(kind) = is_unbatchable_op(v) {
139                return Err(ErrorData::invalid_params(
140                    format!(
141                        "ops[{i}]: {kind} cannot be batched in ops[] \
142                             — send it as a separate top-level ctx_patch call"
143                    ),
144                    None,
145                ));
146            }
147        }
148        if arr.iter().any(|v| delegated_op_kind(v).is_some()) {
149            return handle_mixed_batch(args, arr, ctx);
150        }
151    }
152
153    handle_anchored(args, ctx)
154}
155
156/// Anchored pipeline: a single anchored op or an ops[] batch of anchored edits
157/// (grouped per file, batch-atomic per file). Split out of `handle` so the
158/// mixed-batch path (#1088) can reuse it per anchored run.
159fn handle_anchored(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
160    if get_bool(args, "dry_run").unwrap_or(false) {
161        let path = get_str(args, "path").unwrap_or_default();
162        return Ok(ToolOutput::simple(format!(
163            "DRY RUN: ctx_patch would apply anchor-based ops to {path}"
164        )));
165    }
166
167    // #1592: the guard is a BLAKE3 digest; `expected_md5` was a misnomer and
168    // stays accepted so existing callers keep working.
169    let expected_blake3 =
170        get_str(args, "expected_blake3").or_else(|| get_str(args, "expected_md5"));
171    let backup = get_bool(args, "backup").unwrap_or(false);
172    let backup_path = get_str(args, "backup_path")
173        .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p))
174        .map(|p| lean_ctx::core::workspace_authority::confine(&p, &ctx.project_root, true))
175        .transpose()
176        .map_err(|error| ErrorData::invalid_params(error, None))?;
177    // The diff and digests are opt-in: the default receipt is one line.
178    let evidence = get_bool(args, "evidence").unwrap_or(false);
179    let diff_max_lines = get_int(args, "diff_max_lines")
180        .and_then(|v| usize::try_from(v.max(0)).ok())
181        .unwrap_or(200);
182    let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
183    let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
184
185    // #1005: a batch (`ops[]`) groups its ops by each op's own `path`, so a
186    // batch may span files and needs no top-level `path`. A single op still
187    // requires the top-level `path`.
188    let groups = plan_groups(args, ctx)?;
189    validate_cross_file_options(args, groups.len())?;
190    let output_path = (groups.len() == 1).then(|| groups[0].0.clone());
191    // A one-file receipt already names its file; only a cross-file batch
192    // labels each part with its full path.
193    let label_parts = groups.len() > 1;
194    let diff_artifact = host_draws_diffs(ctx);
195
196    let mut texts = Vec::with_capacity(groups.len());
197    for (path, ops) in groups {
198        let patch_params = crate::tools::ctx_patch::PatchParams {
199            path: path.clone(),
200            ops,
201            expected_blake3: expected_blake3.clone(),
202            backup,
203            backup_path: backup_path.clone(),
204            evidence,
205            diff_artifact,
206            diff_max_lines,
207            allow_lossy_utf8,
208            validate_syntax,
209        };
210        let output =
211            apply_one(ctx, &patch_params).map_err(|error| with_prior_edits(error, &texts))?;
212        texts.push(if label_parts {
213            format!("[{}]\n{output}", receipt_label(&path, &ctx.project_root))
214        } else {
215            output
216        });
217    }
218
219    Ok(ToolOutput {
220        text: texts.join("\n\n"),
221        original_tokens: 0,
222        saved_tokens: 0,
223        mode: None,
224        path: output_path,
225        changed: false,
226        shell_outcome: None,
227        content_blocks: None,
228    })
229}
230
231/// Repairs the argument shapes models send most often instead of rejecting
232/// the call (the pilot's ctx_patch errors were all shape errors, not stale
233/// anchors): `ops` sent as a JSON-encoded string, the text keys of the other
234/// edit tools (`new_string`/`content` for `create` and anchored ops,
235/// `find`/`replace` for `replace_unique`), and an edit that carries old and
236/// new text but no `op`, which can only be `replace_unique` (every other op
237/// needs line numbers or a symbol). Only key names and the array encoding
238/// change; paths are still resolved per op downstream. `None` when nothing
239/// needed repairing.
240fn infer_replace_unique(args: &Map<String, Value>) -> Option<Map<String, Value>> {
241    fn is_text_replacement(obj: &Map<String, Value>) -> bool {
242        let text = |keys: [&str; 2]| keys.iter().find_map(|key| obj.get(*key)?.as_str());
243        !obj.contains_key("op")
244            && text(["old_text", "old_string"]).is_some_and(|old| !old.is_empty())
245            && text(["new_text", "new_string"]).is_some()
246    }
247    /// Copies the first present alias into `key` when `key` is absent.
248    fn alias(obj: &mut Map<String, Value>, key: &str, aliases: &[&str]) -> bool {
249        if obj.contains_key(key) {
250            return false;
251        }
252        let Some(value) = aliases
253            .iter()
254            .find_map(|alias| obj.get(*alias).filter(|v| v.is_string()).cloned())
255        else {
256            return false;
257        };
258        obj.insert(key.to_string(), value);
259        true
260    }
261    fn repair(obj: &mut Map<String, Value>) -> bool {
262        let mut changed = false;
263        if is_text_replacement(obj) {
264            obj.insert("op".into(), json!("replace_unique"));
265            changed = true;
266        }
267        match obj.get("op").and_then(Value::as_str) {
268            Some("replace_unique") => {
269                changed |= alias(obj, "old_text", &["old_string", "find"]);
270                changed |= alias(obj, "new_text", &["new_string", "replace"]);
271            }
272            Some("create") => changed |= alias(obj, "new_text", &["new_string", "content"]),
273            Some("set_line" | "replace_lines" | "insert_after") => {
274                changed |= alias(obj, "new_text", &["new_string"]);
275            }
276            _ => {}
277        }
278        changed
279    }
280    let mut out = args.clone();
281    let mut repaired = false;
282    if let Some(Value::String(encoded)) = out.get("ops")
283        && let Ok(Value::Array(ops)) = serde_json::from_str::<Value>(encoded)
284    {
285        out.insert("ops".into(), Value::Array(ops));
286        repaired = true;
287    }
288    if !out.contains_key("ops") {
289        repaired |= repair(&mut out);
290    }
291    if let Some(Value::Array(ops)) = out.get_mut("ops") {
292        for op in ops.iter_mut().filter_map(Value::as_object_mut) {
293            repaired |= repair(op);
294        }
295    }
296    repaired.then_some(out)
297}
298
299/// `Some(op)` when a batch op must be dispatched through its own single-op
300/// write path rather than the anchored pipeline (#1088).
301fn delegated_op_kind(v: &Value) -> Option<&str> {
302    v.get("op")
303        .and_then(Value::as_str)
304        .filter(|k| matches!(*k, "replace_unique" | "replace_symbol"))
305}
306
307/// #1088: an ops[] batch containing replace_unique/replace_symbol. Ops apply
308/// strictly in the order given, as if issued as separate calls: consecutive
309/// anchored ops flush as one shared-preimage batch; each delegated op is
310/// dispatched through the same delegate as its top-level form, evaluated
311/// against the file state left by the preceding ops.
312/// Ops that require their own top-level ctx_patch call and cannot be
313/// batched inside ops[].
314fn is_unbatchable_op(v: &Value) -> Option<&str> {
315    v.get("op")
316        .and_then(Value::as_str)
317        .filter(|k| matches!(*k, "replace_all" | "create"))
318}
319
320fn handle_mixed_batch(
321    args: &Map<String, Value>,
322    arr: &[Value],
323    ctx: &ToolContext,
324) -> Result<ToolOutput, ErrorData> {
325    // GH #1433: pre-validate the entire batch before applying any op.
326    for (i, v) in arr.iter().enumerate() {
327        if !v.is_object() {
328            return Err(ErrorData::invalid_params(
329                format!("ops[{i}] must be an object"),
330                None,
331            ));
332        }
333        if let Some(kind) = is_unbatchable_op(v) {
334            return Err(ErrorData::invalid_params(
335                format!(
336                    "ops[{i}]: {kind} cannot be batched in ops[] \
337                     — send it as a separate top-level ctx_patch call"
338                ),
339                None,
340            ));
341        }
342    }
343
344    // Every receipt already names its file ("patched cli.py: …"); a label is
345    // only needed to tell files apart, so a one-file batch carries none.
346    let top_path = get_str(args, "path");
347    let mut targets = std::collections::BTreeSet::new();
348    let mut unknown_target = false;
349    for v in arr {
350        match v
351            .get("path")
352            .and_then(Value::as_str)
353            .or(top_path.as_deref())
354        {
355            Some(p) => {
356                targets.insert(p);
357            }
358            None => unknown_target = true,
359        }
360    }
361    let label_parts = unknown_target || targets.len() > 1;
362
363    let mut texts: Vec<String> = Vec::new();
364    let mut run: Vec<Value> = Vec::new();
365    for (i, v) in arr.iter().enumerate() {
366        let obj = v.as_object().ok_or_else(|| {
367            ErrorData::invalid_params(format!("ops[{i}] must be an object"), None)
368        })?;
369        let Some(kind) = delegated_op_kind(v) else {
370            run.push(v.clone());
371            continue;
372        };
373        flush_anchored_run(args, ctx, &mut run, &mut texts)?;
374
375        let (sub_args, sub_ctx) = delegated_op_call(args, obj, ctx, i, kind)
376            .map_err(|error| with_prior_edits(error, &texts))?;
377        let out = if kind == "replace_unique" {
378            delegate_replace_unique(&sub_args, &sub_ctx)
379        } else {
380            delegate_replace_symbol(&sub_args, &sub_ctx)
381        }
382        .map_err(|mut e| {
383            e.message = format!("ops[{i}] ({kind}): {}", e.message).into();
384            with_prior_edits(e, &texts)
385        })?;
386        if label_parts {
387            let label = sub_ctx.resolved_path("path").map_or_else(
388                || kind.to_string(),
389                |resolved| receipt_label(resolved, &ctx.project_root),
390            );
391            texts.push(format!("[{label}]\n{}", out.text));
392        } else {
393            texts.push(out.text);
394        }
395    }
396    flush_anchored_run(args, ctx, &mut run, &mut texts)?;
397
398    Ok(ToolOutput::simple(texts.join("\n\n")))
399}
400
401/// The label of one file's receipt in a cross-file batch: the path relative to
402/// the project root (the spelling the model can send back), or the full path
403/// for a file outside it. The root is tried as given and canonicalized, since
404/// the resolved path may carry the canonical spelling (`/private/var` on macOS,
405/// the long name without `\\?\` on Windows, where paths compare case-insensitively).
406fn receipt_label(resolved: &str, project_root: &str) -> String {
407    let display = lean_ctx::core::protocol::display_path(resolved);
408    let canonical_root = std::fs::canonicalize(project_root).ok().map(|p| {
409        lean_ctx::core::pathutil::strip_verbatim(p)
410            .to_string_lossy()
411            .into_owned()
412    });
413    for root in std::iter::once(project_root.to_string()).chain(canonical_root) {
414        let root = lean_ctx::core::protocol::display_path(&root);
415        let root = root.trim_end_matches('/');
416        let (Some(head), Some(rest)) = (display.get(..root.len()), display.get(root.len()..))
417        else {
418            continue;
419        };
420        if root.is_empty() || rest.len() < 2 {
421            continue;
422        }
423        let same_root = if cfg!(windows) {
424            head.eq_ignore_ascii_case(root)
425        } else {
426            head == root
427        };
428        if same_root && let Some(rel) = rest.strip_prefix('/') {
429            return rel.to_string();
430        }
431    }
432    display
433}
434
435/// Flush buffered anchored ops through the anchored pipeline as one batch.
436fn flush_anchored_run(
437    args: &Map<String, Value>,
438    ctx: &ToolContext,
439    run: &mut Vec<Value>,
440    texts: &mut Vec<String>,
441) -> Result<(), ErrorData> {
442    if run.is_empty() {
443        return Ok(());
444    }
445    let mut sub = args.clone();
446    sub.insert("ops".into(), Value::Array(std::mem::take(run)));
447    texts.push(
448        handle_anchored(&sub, ctx)
449            .map_err(|error| with_prior_edits(error, texts))?
450            .text,
451    );
452    Ok(())
453}
454
455/// A cross-file or mixed batch is not a transaction across completed runs.
456/// Preserve their receipts when a later edit fails, and stop before more writes.
457fn with_prior_edits(mut error: ErrorData, texts: &[String]) -> ErrorData {
458    if !texts.is_empty() {
459        error.code = crate::server::tool_trait::TOOL_EXECUTION_ERROR;
460        error.message = format!(
461            "{}\nEarlier ops in this batch were already applied:\n{}",
462            error.message,
463            texts.join("\n\n")
464        )
465        .into();
466    }
467    error
468}
469
470/// Build the (args, ctx) for one delegated batch op: the op object inherits the
471/// batch's top-level `path`/`dry_run` when it doesn't set its own, and the op's
472/// path is resolved into a per-op ctx — the dispatch layer only pre-resolves
473/// the top-level `path`, which may differ or be absent (#1088).
474fn delegated_op_call(
475    args: &Map<String, Value>,
476    op: &Map<String, Value>,
477    ctx: &ToolContext,
478    i: usize,
479    kind: &str,
480) -> Result<(Map<String, Value>, ToolContext), ErrorData> {
481    let mut sub = op.clone();
482    for key in ["path", "dry_run"] {
483        if !sub.contains_key(key)
484            && let Some(v) = args.get(key)
485        {
486            sub.insert(key.to_string(), v.clone());
487        }
488    }
489
490    let mut sub_ctx = ctx.clone();
491    sub_ctx.resolved_paths.remove("path");
492    sub_ctx.path_errors.remove("path");
493    match get_str(&sub, "path") {
494        Some(raw) => {
495            let resolved = sub_ctx
496                .resolve_path_sync(&raw)
497                .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: path: {e}"), None))?;
498            sub_ctx
499                .ensure_writable(&resolved)
500                .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: {e}"), None))?;
501            sub_ctx.resolved_paths.insert("path".to_string(), resolved);
502        }
503        // replace_symbol's `name` route resolves its own path inside
504        // ctx_refactor; replace_unique always needs one.
505        None if kind == "replace_unique" => {
506            return Err(ErrorData::invalid_params(
507                format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)"),
508                None,
509            ));
510        }
511        None => {}
512    }
513    Ok((sub, sub_ctx))
514}
515
516/// One-shot content-anchored edit (#1010). Delegate to ctx_edit's audited
517/// unique-replacement path so ambiguity checks, TOCTOU guards, atomic writes,
518/// cache invalidation, evidence and session tracking remain single-sourced.
519fn delegate_replace_unique(
520    args: &Map<String, Value>,
521    ctx: &ToolContext,
522) -> Result<ToolOutput, ErrorData> {
523    let edit_args =
524        build_unique_edit_args(args).map_err(|message| ErrorData::invalid_params(message, None))?;
525
526    // #2223: an empty old_text quotes nothing, so it can only fill a file that
527    // holds nothing. Admit it for an existing 0-byte regular file only; ctx_edit
528    // re-checks emptiness under its per-file lock right before the atomic write,
529    // so content that appears in between is refused, never overwritten.
530    if get_str(&edit_args, "old_string").is_some_and(|old| old.is_empty()) {
531        let path = require_resolved_path(ctx, args, "path")?;
532        if let Some(message) = empty_old_text_refusal(std::path::Path::new(&path)) {
533            return Err(ErrorData::invalid_params(message, None));
534        }
535    }
536
537    if get_bool(args, "dry_run").unwrap_or(false) {
538        let old_text = get_str(args, "old_text")
539            .or_else(|| get_str(args, "old_string"))
540            .unwrap_or_default();
541        let new_text = get_str(args, "new_text")
542            .or_else(|| get_str(args, "new_string"))
543            .unwrap_or_default();
544        let path = get_str(args, "path").unwrap_or_default();
545        return Ok(ToolOutput::simple(format!(
546            "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
547        )));
548    }
549
550    crate::tools::registered::ctx_edit::handle_edit(&edit_args, ctx, host_draws_diffs(ctx))
551}
552
553/// Why an empty `old_text` cannot target `path`, naming the op that can; `None`
554/// for an existing empty regular file (a symlink is refused, as `op=create`
555/// refuses it).
556fn empty_old_text_refusal(path: &std::path::Path) -> Option<String> {
557    let shown = path.display();
558    match std::fs::symlink_metadata(path) {
559        Ok(meta) if meta.file_type().is_file() && meta.len() == 0 => None,
560        Ok(meta) if meta.file_type().is_file() => Some(format!(
561            "replace_unique with empty old_text only fills an existing empty (0-byte) file; \
562             {shown} has content. Quote unique text from it as old_text, or use anchored ops \
563             (ctx_read mode=\"anchored\" → insert_after/set_line/replace_lines)"
564        )),
565        Ok(_) => Some(format!(
566            "replace_unique with empty old_text only fills an existing empty regular file; \
567             {shown} is not one (symlink or directory)"
568        )),
569        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some(format!(
570            "replace_unique with empty old_text only fills an existing empty file; \
571             {shown} does not exist — use op=create to write a new file"
572        )),
573        Err(error) => Some(format!("cannot inspect {shown}: {error}")),
574    }
575}
576
577fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
578    // An empty old_text is admitted here; `delegate_replace_unique` limits it
579    // to an existing empty file (#2223).
580    let old_text = get_str(args, "old_text")
581        .or_else(|| get_str(args, "old_string"))
582        .ok_or_else(|| {
583            "replace_unique requires old_text (old_string also accepted); an empty \
584             old_text fills an existing empty file"
585                .to_string()
586        })?;
587    let new_text = get_str(args, "new_text")
588        .or_else(|| get_str(args, "new_string"))
589        .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
590
591    let mut edit_args = args.clone();
592    edit_args.insert("old_string".into(), Value::String(old_text));
593    edit_args.insert("new_string".into(), Value::String(new_text));
594    edit_args.insert("replace_all".into(), Value::Bool(false));
595    edit_args.remove("old_text");
596    edit_args.remove("op");
597    // ctx_edit's create=true overwrites a whole file; replace_unique never
598    // means that, so a stray `create` key must not reach it.
599    edit_args.remove("create");
600    Ok(edit_args)
601}
602
603/// Options with one top-level value cannot be applied safely to multiple files:
604/// one digest cannot describe several preimages, and one explicit backup path
605/// would be overwritten by the second file. Reject before any write occurs.
606fn validate_cross_file_options(
607    args: &Map<String, Value>,
608    file_count: usize,
609) -> Result<(), ErrorData> {
610    if file_count <= 1 {
611        return Ok(());
612    }
613    for key in ["expected_blake3", "expected_md5", "backup_path"] {
614        if args.contains_key(key) {
615            return Err(ErrorData::invalid_params(
616                format!("cross-file ctx_patch batches do not support top-level '{key}'"),
617                None,
618            ));
619        }
620    }
621    Ok(())
622}
623
624/// Build the per-file work list. A single op targets the top-level `path`
625/// (still required). A batch (`ops[]`) groups its ops by each op's own `path`,
626/// falling back to the top-level `path`, so a batch may span files without a
627/// top-level `path` (#1005).
628fn plan_groups(
629    args: &Map<String, Value>,
630    ctx: &ToolContext,
631) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
632    let Some(ops_val) = args.get("ops") else {
633        let path = require_resolved_path(ctx, args, "path")?;
634        let ops = crate::tools::ctx_patch::parse_ops(args)
635            .map_err(|e| ErrorData::invalid_params(e, None))?;
636        return Ok(vec![(path, ops)]);
637    };
638
639    let arr = ops_val
640        .as_array()
641        .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
642    let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
643        .map_err(|e| ErrorData::invalid_params(e, None))?;
644
645    let mut groups = Vec::with_capacity(grouped.len());
646    for (raw_path, op_objs) in grouped {
647        let resolved = ctx
648            .resolve_path_sync(&raw_path)
649            .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
650        ctx.ensure_writable(&resolved)
651            .map_err(|e| ErrorData::invalid_params(e, None))?;
652        let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
653        let ops = crate::tools::ctx_patch::parse_ops(&sub)
654            .map_err(|e| ErrorData::invalid_params(e, None))?;
655        groups.push((resolved, ops));
656    }
657    Ok(groups)
658}
659
660/// Pure grouping: bucket batch op objects by their own `path` (or the batch's
661/// top-level `path` fallback), preserving first-seen file order. Errors if an op
662/// names no path and there is no top-level fallback.
663fn group_ops_by_path(
664    ops: &[Value],
665    top_path: Option<&str>,
666) -> Result<Vec<(String, Vec<Value>)>, String> {
667    if ops.is_empty() {
668        return Err("ops[] is empty — provide at least one edit".to_string());
669    }
670    let mut order: Vec<String> = Vec::new();
671    let mut by_path: std::collections::HashMap<String, Vec<Value>> =
672        std::collections::HashMap::new();
673    for (i, op) in ops.iter().enumerate() {
674        let obj = op
675            .as_object()
676            .ok_or_else(|| format!("ops[{i}] must be an object"))?;
677        let raw = obj
678            .get("path")
679            .and_then(Value::as_str)
680            .map(str::to_string)
681            .or_else(|| top_path.map(str::to_string))
682            .ok_or_else(|| {
683                format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
684            })?;
685        if !by_path.contains_key(&raw) {
686            order.push(raw.clone());
687        }
688        by_path.entry(raw).or_default().push(op.clone());
689    }
690    Ok(order
691        .into_iter()
692        .map(|p| {
693            let ops = by_path.remove(&p).unwrap_or_default();
694            (p, ops)
695        })
696        .collect())
697}
698
699/// Apply one file's anchored patch: acquire the per-file lock, run the I/O off
700/// the global cache lock, then fold the resulting cache effect and session mark.
701/// Returns the rendered patch/CONFLICT text. Shared by the single-op and the
702/// per-file batch paths (#1005).
703fn apply_one(
704    ctx: &ToolContext,
705    params: &crate::tools::ctx_patch::PatchParams,
706) -> Result<String, ErrorData> {
707    let path = params.path.clone();
708    {
709        let cache_lock = ctx
710            .cache
711            .as_ref()
712            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
713
714        // Serialize edits to the SAME file via the shared per-file lock (the same
715        // registry ctx_edit/ctx_read use), so anchored and str_replace edits of
716        // one file never interleave (issue #320). Correctness across processes
717        // still rests on the TOCTOU preimage guard + atomic rename.
718        let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
719        let _file_guard = {
720            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
721            loop {
722                if let Ok(guard) = file_lock.try_lock() {
723                    break guard;
724                }
725                if std::time::Instant::now() >= deadline {
726                    return Err(ErrorData::internal_error(
727                        format!(
728                            "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
729                        ),
730                        None,
731                    ));
732                }
733                std::thread::sleep(std::time::Duration::from_millis(20));
734            }
735        };
736
737        let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
738        {
739            Some(cache) => cache
740                .get(&path)
741                .map(|e| e.last_mode.clone())
742                .unwrap_or_default(),
743            None => String::new(),
744        };
745
746        // Heavy disk I/O — no global cache lock held here.
747        let before = std::fs::read(&path).unwrap_or_default();
748        let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
749        let written = matches!(effect, crate::tools::ctx_edit::CacheEffect::Invalidate);
750
751        if written {
752            let after = std::fs::read(&path).unwrap_or_default();
753            super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &before, &after);
754        }
755
756        crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
757
758        if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
759            crate::tools::ctx_read::dedup_hook::on_write(&path);
760            match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
761                Some(mut cache) => {
762                    crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
763                }
764                None => {
765                    tracing::warn!(
766                        "ctx_patch: cache write-lock timeout applying effect for {path}"
767                    );
768                }
769            }
770        }
771
772        if !written {
773            return Err(tool_execution_error(output));
774        }
775
776        if let Some(session_lock) = ctx.session.as_ref() {
777            if let Some(mut session) =
778                crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
779            {
780                session.mark_modified(&path);
781            }
782        }
783
784        Ok(output)
785    }
786}
787
788/// Handle `op="replace_symbol"` by translating to `ctx_refactor`'s
789/// `replace_symbol_body` and dispatching through it. The symbol-resolution,
790/// CONFLICT guard and atomic write all live in ctx_refactor — this is a thin,
791/// pure-mapping adapter (mapping logic lives in `ctx_patch::symbol`).
792fn delegate_replace_symbol(
793    args: &Map<String, Value>,
794    ctx: &ToolContext,
795) -> Result<ToolOutput, ErrorData> {
796    let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
797        .map_err(|e| ErrorData::invalid_params(e, None))?;
798
799    if get_bool(args, "dry_run").unwrap_or(false) {
800        let name = get_str(args, "name").unwrap_or_default();
801        let path = get_str(args, "path").unwrap_or_default();
802        return Ok(ToolOutput::simple(format!(
803            "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
804        )));
805    }
806
807    // Resolve `path` at the boundary when given (the name route resolves its own
808    // path inside ctx_refactor). abs_path is unused by the symbol-edit branch but
809    // we mirror ctx_refactor's wrapper to keep jail behaviour identical.
810    let has_path = args.get("path").and_then(Value::as_str).is_some();
811    let abs_path = if has_path {
812        require_resolved_path(ctx, args, "path")?
813    } else {
814        String::new()
815    };
816
817    let args_value = Value::Object(refactor_args);
818    let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
819    let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
820    if !changed {
821        return Err(tool_execution_error(result));
822    }
823
824    Ok(ToolOutput {
825        text: result,
826        original_tokens: 0,
827        saved_tokens: 0,
828        mode: Some("replace_symbol".to_string()),
829        path: get_str(args, "path"),
830        changed,
831        shell_outcome: None,
832        content_blocks: None,
833    })
834}
835
836/// #879: resolve `find`/`replace` for replace_all, failing *closed* on the
837/// destructive path. Historically a missing `replace` defaulted to "" — so a
838/// typo'd replacement key (`new_string=`/`new_text=` carried over from the other
839/// ops) meant "delete every match" and still reported success. Now: reject
840/// replacement keys that belong to other ops, and require `replace` to be
841/// present. An empty deletion must be opted into explicitly with `replace=""`.
842///
843/// The other ops' names are accepted as aliases — `old_string`/`old_text` for
844/// `find`, `new_string`/`new_text` for `replace` — because agents carry them
845/// over and the mapping is unambiguous. What stays closed is the #879 hole: a
846/// replacement must still be *named*, so a missing one is never read as "".
847fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
848    let find = aliased(args, "find", &["old_string", "old_text"])?
849        .filter(|s| !s.is_empty())
850        .ok_or("replace_all requires non-empty 'find' (old_string also accepted)")?;
851    let replace = aliased(args, "replace", &["new_string", "new_text"])?.ok_or(
852        "replace_all requires 'replace' (the replacement text, new_string also accepted); \
853         pass replace=\"\" explicitly to delete every match",
854    )?;
855    Ok((find, replace))
856}
857
858/// The string under `key` or one of its `aliases`. Two spellings that
859/// disagree are an error rather than a silent pick.
860fn aliased(
861    args: &Map<String, Value>,
862    key: &str,
863    aliases: &[&str],
864) -> Result<Option<String>, String> {
865    let mut found: Option<(&str, &str)> = None;
866    for name in std::iter::once(key).chain(aliases.iter().copied()) {
867        let Some(value) = args.get(name).and_then(Value::as_str) else {
868            continue;
869        };
870        match found {
871            Some((first, seen)) if seen != value => {
872                return Err(format!(
873                    "'{first}' and '{name}' name the same field with different values — pass only one"
874                ));
875            }
876            Some(_) => {}
877            None => found = Some((name, value)),
878        }
879    }
880    Ok(found.map(|(_, value)| value.to_string()))
881}
882
883/// What a bulk replace works from: the ORIGINAL text (the only thing that is
884/// ever written back, with the replacement applied) and the match count the
885/// caller is told.
886///
887/// The count is model-visible and is a statement about the file's content, so
888/// it is taken from the ADMITTED text, read through the common rooted reader
889/// (G01). A source that is withheld is neither counted nor edited. When the
890/// admitted text and the original disagree about the matches, the pattern
891/// touches content the read path masks: answering with the original's count
892/// would confirm a guess of a masked value, and replacing would edit content
893/// the caller cannot see — the operation is refused and nothing is written.
894/// Admitted or redacted text is never written to disk.
895///
896/// The original is read through the shared edit boundary (`edit_io`: symlink
897/// rejection, size cap, strict UTF-8), and its fingerprint is what the write
898/// is later checked against.
899fn replace_all_plan(
900    path: &str,
901    root: &str,
902    find: &str,
903) -> Result<(crate::tools::edit_io::FilePreimage, usize), String> {
904    use crate::tools::admitted_sources::SourceRead;
905    let cap = lean_ctx::core::limits::max_read_bytes();
906    let original = crate::tools::edit_io::read_preimage(std::path::Path::new(path), cap, false)?;
907    let mut remaining = cap;
908    let admitted =
909        match crate::tools::admitted_sources::read(path, root, "ctx_patch", &mut remaining) {
910            SourceRead::Admitted(read) => read.content,
911            SourceRead::Withheld(reason) | SourceRead::Unavailable(reason) => {
912                return Err(format!("cannot read {path}: {reason}"));
913            }
914        };
915    let count = admitted.matches(find).count();
916    if count != original.text.matches(find).count() {
917        return Err(format!(
918            "replace_all refused for {path}: the pattern overlaps content the context gateway \
919             masks; nothing was changed"
920        ));
921    }
922    Ok((original, count))
923}
924
925/// Writes a bulk replace through the shared edit boundary: the file must still
926/// match the fingerprint it was planned from (TOCTOU guard), and the write
927/// rejects symlinks and read-only roots and keeps the file's permissions.
928fn replace_all_write(
929    path: &str,
930    pre: &crate::tools::edit_io::FilePreimage,
931    new_content: &str,
932) -> Result<(), String> {
933    let path = std::path::Path::new(path);
934    crate::tools::claims_gate::before_write(path, Some(&pre.text), new_content)?;
935    crate::tools::edit_io::ensure_preimage_still_matches(
936        path,
937        &pre.fp,
938        lean_ctx::core::limits::max_read_bytes(),
939    )?;
940    crate::tools::edit_io::write_atomic_bytes_with_permissions(
941        path,
942        new_content.as_bytes(),
943        Some(&pre.permissions),
944    )
945}
946
947/// #825: Bulk literal find-and-replace — no anchors needed.
948fn handle_replace_all(
949    args: &Map<String, Value>,
950    ctx: &ToolContext,
951) -> Result<ToolOutput, ErrorData> {
952    let path = require_resolved_path(ctx, args, "path")?;
953    let (find, replace) =
954        resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
955    let dry_run = get_bool(args, "dry_run").unwrap_or(false);
956
957    // The per-file lock is held from the read through the write, so another
958    // edit of this file in this process cannot land between them.
959    let file_lock = lean_ctx::core::path_locks::per_file_lock(&path);
960    let _guard = file_lock
961        .lock()
962        .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
963
964    let root = crate::tools::admitted_sources::root_in_context(&path, ctx);
965    let (pre, count) = replace_all_plan(&path, &root, &find).map_err(tool_execution_error)?;
966    if count == 0 {
967        return Ok(ToolOutput::simple(format!(
968            "No matches for {find:?} in {path}"
969        )));
970    }
971
972    if dry_run {
973        return Ok(ToolOutput::simple(format!(
974            "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
975        )));
976    }
977
978    let new_content = pre.text.replace(find.as_str(), &replace);
979    replace_all_write(&path, &pre, &new_content).map_err(tool_execution_error)?;
980    super::ctx_edit::observe_mcp_edit(ctx, &path, "ctx_patch", &pre.bytes, new_content.as_bytes());
981
982    crate::tools::ctx_read::dedup_hook::on_write(&path);
983    if let Some(cache) = ctx.cache.as_ref() {
984        if let Some(mut c) =
985            crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
986        {
987            c.invalidate(&path);
988        }
989    }
990    if let Some(session_lock) = ctx.session.as_ref() {
991        if let Some(mut session) =
992            crate::server::bounded_lock::write(session_lock, "ctx_patch replace_all session")
993        {
994            session.mark_modified(&path);
995        }
996    }
997
998    Ok(ToolOutput::simple(format!(
999        "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
1000    )))
1001}
1002
1003#[cfg(test)]
1004mod replace_all_tests {
1005    use super::*;
1006    use serde_json::json;
1007
1008    fn obj(v: Value) -> Map<String, Value> {
1009        match v {
1010            Value::Object(m) => m,
1011            _ => panic!("expected object"),
1012        }
1013    }
1014
1015    #[test]
1016    fn resolves_find_and_replace() {
1017        let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
1018        assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
1019    }
1020
1021    #[test]
1022    fn explicit_empty_replace_is_a_deletion() {
1023        let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
1024        assert_eq!(r, "");
1025    }
1026
1027    #[test]
1028    fn missing_replace_is_rejected_not_silent_delete() {
1029        let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
1030        assert!(err.contains("requires 'replace'"), "got: {err}");
1031    }
1032
1033    #[test]
1034    fn other_ops_names_are_aliases() {
1035        for (old, new) in [("old_string", "new_string"), ("old_text", "new_text")] {
1036            let (f, r) = resolve_find_replace(&obj(json!({old: "a", new: "b"}))).unwrap();
1037            assert_eq!((f.as_str(), r.as_str()), ("a", "b"), "{old}/{new}");
1038        }
1039    }
1040
1041    #[test]
1042    fn alias_never_turns_a_missing_replacement_into_a_delete() {
1043        let err = resolve_find_replace(&obj(json!({"old_string": "a"}))).unwrap_err();
1044        assert!(err.contains("requires 'replace'"), "got: {err}");
1045    }
1046
1047    #[test]
1048    fn conflicting_spellings_are_rejected() {
1049        let err = resolve_find_replace(&obj(
1050            json!({"find": "a", "old_string": "x", "replace": "b"}),
1051        ))
1052        .unwrap_err();
1053        assert!(
1054            err.contains("find") && err.contains("old_string"),
1055            "got: {err}"
1056        );
1057        let same = resolve_find_replace(&obj(
1058            json!({"find": "a", "old_string": "a", "replace": "b"}),
1059        ));
1060        assert!(same.is_ok(), "identical spellings agree");
1061    }
1062
1063    #[test]
1064    fn empty_find_is_rejected() {
1065        let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
1066        assert!(err.contains("find"), "got: {err}");
1067    }
1068
1069    /// G01: `replace_all` read the file with `fs::read_to_string` and reported
1070    /// how often the pattern occurs — for any file, also one the request may
1071    /// not read, and also for a value the read path masks. The count now
1072    /// comes from the admitted text; the text that is written stays the
1073    /// original.
1074    #[test]
1075    fn the_reported_count_comes_from_the_admitted_text_and_the_original_is_what_is_written() {
1076        use lean_ctx::core::policy::runtime::TestPolicyOverride;
1077        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1078        let policy = lean_ctx::core::policy::load(
1079            "name = \"ctx-patch-test\"\nversion = \"1.0.0\"\ndescription = \"test\"\n\
1080             [redaction]\ncustomer = \"CUS-[0-9]{4}\"\n\
1081             [filters]\nclassification = \"block\"\nblocked_labels = [\"CONFIDENTIAL\"]\n",
1082        )
1083        .unwrap();
1084        let project = tempfile::tempdir().unwrap();
1085        let root = project.path().to_string_lossy().into_owned();
1086        let file = |name: &str, content: &str| {
1087            let path = project.path().join(name);
1088            std::fs::write(&path, content).unwrap();
1089            path.to_string_lossy().into_owned()
1090        };
1091        let original = "let owner = \"CUS-1234\";\nlet mode = old_mode();\nold_mode();\n";
1092        let orders = file("orders.rs", original);
1093        let plan = file("plan.rs", "CONFIDENTIAL\nold_mode();\n");
1094        let planned = |path: &str, find: &str| {
1095            replace_all_plan(path, &root, find).map(|(pre, count)| (pre.text, count))
1096        };
1097        {
1098            // Control without a pack: original and count as before.
1099            let _community = TestPolicyOverride::set(None);
1100            assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1101        }
1102        let _policy = TestPolicyOverride::set(Some(policy));
1103        // Unmasked content: the ORIGINAL is returned for the write.
1104        assert_eq!(planned(&orders, "old_mode"), Ok((original.to_string(), 2)));
1105        // A guess of the masked value is neither confirmed nor replaced.
1106        let refused = replace_all_plan(&orders, &root, "CUS-1234").unwrap_err();
1107        assert!(refused.contains("refused"), "{refused}");
1108        assert!(!refused.contains("1 occurrence"), "{refused}");
1109        // A withheld source is not counted at all.
1110        let withheld = replace_all_plan(&plan, &root, "old_mode").unwrap_err();
1111        assert!(withheld.contains("withheld"), "{withheld}");
1112        // No project root: nothing is read.
1113        assert!(replace_all_plan(&orders, "", "old_mode").is_err());
1114    }
1115
1116    /// F4: replace_all reads and writes through the shared edit boundary, so a
1117    /// symlink is refused on the read and a file that changed after planning
1118    /// is not overwritten (TOCTOU guard).
1119    #[test]
1120    fn replace_all_uses_the_edit_boundary() {
1121        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1122        let project = tempfile::tempdir().unwrap();
1123        let root = project.path().to_string_lossy().into_owned();
1124        let target = project.path().join("a.rs");
1125        std::fs::write(&target, "old();\nold();\n").unwrap();
1126        let target_str = target.to_string_lossy().into_owned();
1127
1128        #[cfg(unix)]
1129        {
1130            let link = project.path().join("link.rs");
1131            std::os::unix::fs::symlink(&target, &link).unwrap();
1132            let err = replace_all_plan(&link.to_string_lossy(), &root, "old").unwrap_err();
1133            assert!(err.contains("symlink"), "{err}");
1134        }
1135
1136        let (pre, count) = replace_all_plan(&target_str, &root, "old").unwrap();
1137        assert_eq!(count, 2);
1138        // A concurrent writer changes the file after the plan was read.
1139        std::fs::write(&target, "old();\nnewer();\n").unwrap();
1140        let new_content = pre.text.replace("old", "new");
1141        let err = replace_all_write(&target_str, &pre, &new_content).unwrap_err();
1142        assert!(err.contains("TOCTOU"), "{err}");
1143        assert_eq!(
1144            std::fs::read_to_string(&target).unwrap(),
1145            "old();\nnewer();\n"
1146        );
1147
1148        // Unchanged since the plan: the write lands.
1149        let (pre, _) = replace_all_plan(&target_str, &root, "old").unwrap();
1150        let new_content = pre.text.replace("old", "new");
1151        replace_all_write(&target_str, &pre, &new_content).unwrap();
1152        assert_eq!(
1153            std::fs::read_to_string(&target).unwrap(),
1154            "new();\nnewer();\n"
1155        );
1156    }
1157}
1158
1159#[cfg(test)]
1160mod batch_grouping_tests {
1161    use super::*;
1162    use serde_json::json;
1163
1164    /// #1005: a batch spanning two files groups ops per file, preserving the
1165    /// first-seen file order — no top-level `path` needed.
1166    #[test]
1167    fn groups_ops_across_files_preserving_order() {
1168        let ops = vec![
1169            json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
1170            json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
1171            json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
1172        ];
1173        let g = group_ops_by_path(&ops, None).unwrap();
1174        assert_eq!(g.len(), 2);
1175        assert_eq!(g[0].0, "a.go");
1176        assert_eq!(g[0].1.len(), 2);
1177        assert_eq!(g[1].0, "b.go");
1178        assert_eq!(g[1].1.len(), 1);
1179    }
1180
1181    #[test]
1182    fn ops_without_path_fall_back_to_top_level() {
1183        let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1184        let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
1185        assert_eq!(g.len(), 1);
1186        assert_eq!(g[0].0, "top.go");
1187    }
1188
1189    #[test]
1190    fn op_without_path_and_no_top_level_is_rejected() {
1191        let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
1192        let err = group_ops_by_path(&ops, None).unwrap_err();
1193        assert!(err.contains("path"), "got: {err}");
1194    }
1195
1196    #[test]
1197    fn empty_ops_rejected() {
1198        let err = group_ops_by_path(&[], None).unwrap_err();
1199        assert!(err.contains("empty"), "got: {err}");
1200    }
1201
1202    #[test]
1203    fn cross_file_rejects_single_value_preimage_and_backup_options() {
1204        for key in ["expected_blake3", "expected_md5", "backup_path"] {
1205            let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
1206            assert!(validate_cross_file_options(&args, 2).is_err());
1207            assert!(validate_cross_file_options(&args, 1).is_ok());
1208        }
1209    }
1210
1211    #[test]
1212    fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
1213        let args = Map::from_iter([
1214            ("path".into(), json!("a.rs")),
1215            ("op".into(), json!("replace_unique")),
1216            ("old_text".into(), json!("old")),
1217            ("new_text".into(), json!("new")),
1218        ]);
1219        let mapped = build_unique_edit_args(&args).expect("valid mapping");
1220        assert_eq!(mapped.get("old_string"), Some(&json!("old")));
1221        assert_eq!(mapped.get("new_string"), Some(&json!("new")));
1222        assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
1223        assert!(!mapped.contains_key("op"));
1224        assert!(!mapped.contains_key("old_text"));
1225    }
1226
1227    #[test]
1228    fn replace_unique_requires_explicit_old_and_new_text() {
1229        assert!(build_unique_edit_args(&Map::new()).is_err());
1230        let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
1231        assert!(build_unique_edit_args(&only_old).is_err());
1232    }
1233
1234    #[test]
1235    fn dry_run_replace_unique_does_not_apply() {
1236        let args = Map::from_iter([
1237            ("path".into(), json!("a.rs")),
1238            ("op".into(), json!("replace_unique")),
1239            ("old_text".into(), json!("old")),
1240            ("new_text".into(), json!("new")),
1241            ("dry_run".into(), json!(true)),
1242        ]);
1243        let edit_args = build_unique_edit_args(&args).expect("validation passes");
1244        assert!(
1245            edit_args.contains_key("old_string"),
1246            "args mapped correctly"
1247        );
1248        assert!(
1249            args.get("dry_run").and_then(Value::as_bool) == Some(true),
1250            "dry_run flag preserved in original args"
1251        );
1252    }
1253
1254    #[test]
1255    fn text_replacement_without_op_is_inferred_as_replace_unique() {
1256        let top = Map::from_iter([
1257            ("path".into(), json!("a.rs")),
1258            ("old_string".into(), json!("old")),
1259            ("new_string".into(), json!("new")),
1260        ]);
1261        let inferred = infer_replace_unique(&top).expect("op inferred");
1262        assert_eq!(inferred.get("op"), Some(&json!("replace_unique")));
1263
1264        let batch = Map::from_iter([(
1265            "ops".into(),
1266            json!([
1267                {"path": "a.rs", "old_text": "a", "new_text": "b"},
1268                {"op": "set_line", "line": 1, "hash": "aa", "new_text": "x"}
1269            ]),
1270        )]);
1271        let inferred = infer_replace_unique(&batch).expect("op inferred in ops[]");
1272        assert_eq!(inferred["ops"][0]["op"], json!("replace_unique"));
1273        assert_eq!(inferred["ops"][1]["op"], json!("set_line"));
1274
1275        // An explicit op, an empty old text or a missing new text stay as sent.
1276        for unchanged in [
1277            json!({"op": "set_line", "old_text": "a", "new_text": "b"}),
1278            json!({"old_text": "", "new_text": "b"}),
1279            json!({"old_text": "a"}),
1280        ] {
1281            assert!(infer_replace_unique(unchanged.as_object().unwrap()).is_none());
1282        }
1283
1284        // The shapes behind every ctx_patch error of the pilot benchmark.
1285        let encoded = json!({"ops": "[{\"op\":\"replace_unique\",\"path\":\"a.rs\",\"old_text\":\"a\",\"new_text\":\"b\"}]"});
1286        let repaired = infer_replace_unique(encoded.as_object().unwrap()).expect("ops decoded");
1287        assert_eq!(repaired["ops"][0]["old_text"], json!("a"));
1288        let create = json!({"op": "create", "path": "n.py", "new_string": "x = 1\n"});
1289        let repaired = infer_replace_unique(create.as_object().unwrap()).expect("create aliased");
1290        assert_eq!(repaired["new_text"], json!("x = 1\n"));
1291        let find_replace =
1292            json!({"op": "replace_unique", "path": "a.rs", "find": "a", "replace": "b"});
1293        let repaired = infer_replace_unique(find_replace.as_object().unwrap()).expect("aliased");
1294        assert_eq!(
1295            (&repaired["old_text"], &repaired["new_text"]),
1296            (&json!("a"), &json!("b"))
1297        );
1298        // replace_all keeps its own find/replace keys.
1299        let replace_all = json!({"op": "replace_all", "path": "a.rs", "find": "a", "replace": "b"});
1300        assert!(infer_replace_unique(replace_all.as_object().unwrap()).is_none());
1301    }
1302
1303    /// Runs `replace_unique` with an empty old_text against `path` through the
1304    /// full tool handler (PathJail-resolved path, ctx_edit write path).
1305    fn empty_old_text_patch(
1306        root: &std::path::Path,
1307        path: &std::path::Path,
1308    ) -> Result<String, ErrorData> {
1309        let resolved = path.to_string_lossy().into_owned();
1310        let ctx = ToolContext {
1311            project_root: root.to_string_lossy().into_owned(),
1312            resolved_paths: std::collections::HashMap::from([("path".into(), resolved.clone())]),
1313            cache: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
1314                lean_ctx::core::cache::SessionCache::new(),
1315            ))),
1316            ..ToolContext::default()
1317        };
1318        let args = json!({
1319            "op": "replace_unique", "path": resolved, "old_text": "", "new_text": "filled\n"
1320        });
1321        tokio::task::block_in_place(|| CtxPatchTool.handle(args.as_object().unwrap(), &ctx))
1322            .map(|out| out.text)
1323    }
1324
1325    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1326    async fn replace_unique_with_empty_old_text_fills_an_empty_file() {
1327        // #2223: a 0-byte file had no satisfiable replace_unique input.
1328        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1329        let dir = tempfile::tempdir().unwrap();
1330        let file = dir.path().join("empty.txt");
1331        std::fs::write(&file, "").unwrap();
1332        let text = empty_old_text_patch(dir.path(), &file).expect("empty file is filled");
1333        assert!(text.contains("created empty.txt"), "{text}");
1334        assert_eq!(std::fs::read_to_string(&file).unwrap(), "filled\n");
1335    }
1336
1337    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1338    async fn replace_unique_with_empty_old_text_refuses_other_targets() {
1339        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
1340        let dir = tempfile::tempdir().unwrap();
1341
1342        let full = dir.path().join("full.txt");
1343        std::fs::write(&full, "keep\n").unwrap();
1344        let err = empty_old_text_patch(dir.path(), &full).unwrap_err();
1345        assert!(err.message.contains("anchored"), "{}", err.message);
1346        assert_eq!(std::fs::read_to_string(&full).unwrap(), "keep\n");
1347
1348        let missing = dir.path().join("missing.txt");
1349        let err = empty_old_text_patch(dir.path(), &missing).unwrap_err();
1350        assert!(err.message.contains("op=create"), "{}", err.message);
1351        assert!(!missing.exists(), "replace_unique must not create files");
1352
1353        #[cfg(unix)]
1354        {
1355            let target = dir.path().join("target.txt");
1356            std::fs::write(&target, "").unwrap();
1357            let link = dir.path().join("link.txt");
1358            std::os::unix::fs::symlink(&target, &link).unwrap();
1359            let err = empty_old_text_patch(dir.path(), &link).unwrap_err();
1360            assert!(err.message.contains("not one"), "{}", err.message);
1361            assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
1362        }
1363    }
1364
1365    #[test]
1366    fn replace_unique_never_forwards_a_whole_file_create() {
1367        let args = Map::from_iter([
1368            ("old_text".into(), json!("old")),
1369            ("new_text".into(), json!("new")),
1370            ("create".into(), json!(true)),
1371        ]);
1372        let mapped = build_unique_edit_args(&args).expect("valid mapping");
1373        assert!(!mapped.contains_key("create"));
1374    }
1375}