1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6 McpTool, ToolContext, ToolOutput, get_bool, get_int, get_str, require_resolved_path,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxPatchTool;
11
12impl McpTool for CtxPatchTool {
13 fn name(&self) -> &'static str {
14 "ctx_patch"
15 }
16
17 fn tool_def(&self) -> Tool {
23 tool_def(
24 "ctx_patch",
25 "Safe file edit. Anchored ops use line+hash from ctx_read(mode=\"anchored\"); \
26 CONFLICT means re-read. replace_unique(path,old_text,new_text) is a no-read, \
27 exact unique replacement. replace_symbol/create/replace_all and cross-file ops[] \
28 (incl. replace_unique) supported.",
29 json!({
30 "type": "object",
31 "properties": {
32 "path": { "type": "string" },
33 "op": { "type": "string", "enum": ["set_line", "replace_lines", "insert_after", "delete", "replace_unique", "replace_symbol", "create", "replace_all"] },
34 "line": { "type": "integer" },
35 "hash": { "type": "string" },
36 "start_line": { "type": "integer" },
37 "start_hash": { "type": "string" },
38 "end_line": { "type": "integer" },
39 "end_hash": { "type": "string" },
40 "new_text": { "type": "string" },
41 "old_text": { "type": "string" },
42 "name": { "type": "string" },
43 "find": { "type": "string" },
44 "replace": { "type": "string" },
45 "dry_run": { "type": "boolean" },
46 "ops": { "type": "array", "items": { "type": "object" } }
47 },
48 "allOf": [
56 { "if": { "properties": { "op": { "const": "set_line" } }, "required": ["op"] },
57 "then": { "required": ["op", "line", "hash", "new_text"] } },
58 { "if": { "properties": { "op": { "const": "replace_lines" } }, "required": ["op"] },
59 "then": { "required": ["op", "start_line", "start_hash", "end_line", "end_hash", "new_text"] } },
60 { "if": { "properties": { "op": { "const": "replace_unique" } }, "required": ["op"] },
61 "then": { "required": ["op", "old_text", "new_text"] } },
62 { "if": { "properties": { "op": { "const": "replace_symbol" } }, "required": ["op"] },
63 "then": { "required": ["op", "new_text"] } },
64 { "if": { "properties": { "op": { "const": "create" } }, "required": ["op"] },
65 "then": { "required": ["op", "new_text"] } },
66 { "if": { "properties": { "op": { "const": "replace_all" } }, "required": ["op"] },
67 "then": { "required": ["op", "find", "replace"] } }
68 ]
69 }),
70 )
71 }
72
73 fn handle(
74 &self,
75 args: &Map<String, Value>,
76 ctx: &ToolContext,
77 ) -> Result<ToolOutput, ErrorData> {
78 if crate::tools::ctx_patch::is_replace_symbol(args) {
81 return delegate_replace_symbol(args, ctx);
82 }
83
84 if get_str(args, "op").as_deref() == Some("replace_unique") {
85 return delegate_replace_unique(args, ctx);
86 }
87
88 if get_str(args, "op").as_deref() == Some("replace_all") {
90 return handle_replace_all(args, ctx);
91 }
92
93 if let Some(arr) = args.get("ops").and_then(Value::as_array)
97 && arr.iter().any(|v| delegated_op_kind(v).is_some())
98 {
99 return handle_mixed_batch(args, arr, ctx);
100 }
101
102 handle_anchored(args, ctx)
103 }
104}
105
106fn handle_anchored(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
110 if get_bool(args, "dry_run").unwrap_or(false) {
111 let path = get_str(args, "path").unwrap_or_default();
112 return Ok(ToolOutput::simple(format!(
113 "DRY RUN: ctx_patch would apply anchor-based ops to {path}"
114 )));
115 }
116
117 let expected_md5 = get_str(args, "expected_md5");
118 let backup = get_bool(args, "backup").unwrap_or(false);
119 let backup_path = get_str(args, "backup_path")
120 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
121 let evidence = get_bool(args, "evidence").unwrap_or(true);
122 let diff_max_lines = get_int(args, "diff_max_lines")
123 .and_then(|v| usize::try_from(v.max(0)).ok())
124 .unwrap_or(200);
125 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
126 let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
127
128 let groups = plan_groups(args, ctx)?;
132 validate_cross_file_options(args, groups.len())?;
133 let output_path = (groups.len() == 1).then(|| groups[0].0.clone());
134
135 let mut texts = Vec::with_capacity(groups.len());
136 for (path, ops) in groups {
137 let patch_params = crate::tools::ctx_patch::PatchParams {
138 path: path.clone(),
139 ops,
140 expected_md5: expected_md5.clone(),
141 backup,
142 backup_path: backup_path.clone(),
143 evidence,
144 diff_max_lines,
145 allow_lossy_utf8,
146 validate_syntax,
147 };
148 let output = apply_one(ctx, &patch_params)?;
149 texts.push(format!("[{path}]\n{output}"));
150 }
151
152 Ok(ToolOutput {
153 text: texts.join("\n\n"),
154 original_tokens: 0,
155 saved_tokens: 0,
156 mode: None,
157 path: output_path,
158 changed: false,
159 shell_outcome: None,
160 content_blocks: None,
161 })
162}
163
164fn delegated_op_kind(v: &Value) -> Option<&str> {
167 v.get("op")
168 .and_then(Value::as_str)
169 .filter(|k| matches!(*k, "replace_unique" | "replace_symbol"))
170}
171
172fn handle_mixed_batch(
178 args: &Map<String, Value>,
179 arr: &[Value],
180 ctx: &ToolContext,
181) -> Result<ToolOutput, ErrorData> {
182 let mut texts: Vec<String> = Vec::new();
183 let mut run: Vec<Value> = Vec::new();
184 for (i, v) in arr.iter().enumerate() {
185 let obj = v.as_object().ok_or_else(|| {
186 ErrorData::invalid_params(format!("ops[{i}] must be an object"), None)
187 })?;
188 let Some(kind) = delegated_op_kind(v) else {
189 run.push(v.clone());
190 continue;
191 };
192 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
193
194 let (sub_args, sub_ctx) = delegated_op_call(args, obj, ctx, i, kind)?;
195 let out = if kind == "replace_unique" {
196 delegate_replace_unique(&sub_args, &sub_ctx)
197 } else {
198 delegate_replace_symbol(&sub_args, &sub_ctx)
199 }
200 .map_err(|e| {
201 let applied = if texts.is_empty() {
202 ""
203 } else {
204 " (earlier ops in this batch were already applied)"
205 };
206 ErrorData::invalid_params(format!("ops[{i}] ({kind}): {}{applied}", e.message), None)
207 })?;
208 let label = get_str(&sub_args, "path").unwrap_or_else(|| kind.to_string());
209 texts.push(format!("[{label}]\n{}", out.text));
210 }
211 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
212
213 Ok(ToolOutput::simple(texts.join("\n\n")))
214}
215
216fn flush_anchored_run(
218 args: &Map<String, Value>,
219 ctx: &ToolContext,
220 run: &mut Vec<Value>,
221 texts: &mut Vec<String>,
222) -> Result<(), ErrorData> {
223 if run.is_empty() {
224 return Ok(());
225 }
226 let mut sub = args.clone();
227 sub.insert("ops".into(), Value::Array(std::mem::take(run)));
228 texts.push(handle_anchored(&sub, ctx)?.text);
229 Ok(())
230}
231
232fn delegated_op_call(
237 args: &Map<String, Value>,
238 op: &Map<String, Value>,
239 ctx: &ToolContext,
240 i: usize,
241 kind: &str,
242) -> Result<(Map<String, Value>, ToolContext), ErrorData> {
243 let mut sub = op.clone();
244 for key in ["path", "dry_run"] {
245 if !sub.contains_key(key)
246 && let Some(v) = args.get(key)
247 {
248 sub.insert(key.to_string(), v.clone());
249 }
250 }
251
252 let mut sub_ctx = ctx.clone();
253 sub_ctx.resolved_paths.remove("path");
254 sub_ctx.path_errors.remove("path");
255 match get_str(&sub, "path") {
256 Some(raw) => {
257 let resolved = sub_ctx
258 .resolve_path_sync(&raw)
259 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: path: {e}"), None))?;
260 sub_ctx
261 .ensure_writable(&resolved)
262 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: {e}"), None))?;
263 sub_ctx.resolved_paths.insert("path".to_string(), resolved);
264 }
265 None if kind == "replace_unique" => {
268 return Err(ErrorData::invalid_params(
269 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)"),
270 None,
271 ));
272 }
273 None => {}
274 }
275 Ok((sub, sub_ctx))
276}
277
278fn delegate_replace_unique(
282 args: &Map<String, Value>,
283 ctx: &ToolContext,
284) -> Result<ToolOutput, ErrorData> {
285 let edit_args =
286 build_unique_edit_args(args).map_err(|message| ErrorData::invalid_params(message, None))?;
287
288 if get_bool(args, "dry_run").unwrap_or(false) {
289 let old_text = get_str(args, "old_text")
290 .or_else(|| get_str(args, "old_string"))
291 .unwrap_or_default();
292 let new_text = get_str(args, "new_text")
293 .or_else(|| get_str(args, "new_string"))
294 .unwrap_or_default();
295 let path = get_str(args, "path").unwrap_or_default();
296 return Ok(ToolOutput::simple(format!(
297 "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
298 )));
299 }
300
301 crate::tools::registered::ctx_edit::CtxEditTool.handle(&edit_args, ctx)
302}
303
304fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
305 let old_text = get_str(args, "old_text")
306 .or_else(|| get_str(args, "old_string"))
307 .filter(|text| !text.is_empty())
308 .ok_or_else(|| {
309 "replace_unique requires non-empty old_text (old_string also accepted)".to_string()
310 })?;
311 let new_text = get_str(args, "new_text")
312 .or_else(|| get_str(args, "new_string"))
313 .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
314
315 let mut edit_args = args.clone();
316 edit_args.insert("old_string".into(), Value::String(old_text));
317 edit_args.insert("new_string".into(), Value::String(new_text));
318 edit_args.insert("replace_all".into(), Value::Bool(false));
319 edit_args.remove("old_text");
320 edit_args.remove("op");
321 Ok(edit_args)
322}
323
324fn validate_cross_file_options(
328 args: &Map<String, Value>,
329 file_count: usize,
330) -> Result<(), ErrorData> {
331 if file_count <= 1 {
332 return Ok(());
333 }
334 for key in ["expected_md5", "backup_path"] {
335 if args.contains_key(key) {
336 return Err(ErrorData::invalid_params(
337 format!("cross-file ctx_patch batches do not support top-level '{key}'"),
338 None,
339 ));
340 }
341 }
342 Ok(())
343}
344
345fn plan_groups(
350 args: &Map<String, Value>,
351 ctx: &ToolContext,
352) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
353 let Some(ops_val) = args.get("ops") else {
354 let path = require_resolved_path(ctx, args, "path")?;
355 let ops = crate::tools::ctx_patch::parse_ops(args)
356 .map_err(|e| ErrorData::invalid_params(e, None))?;
357 return Ok(vec![(path, ops)]);
358 };
359
360 let arr = ops_val
361 .as_array()
362 .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
363 let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
364 .map_err(|e| ErrorData::invalid_params(e, None))?;
365
366 let mut groups = Vec::with_capacity(grouped.len());
367 for (raw_path, op_objs) in grouped {
368 let resolved = ctx
369 .resolve_path_sync(&raw_path)
370 .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
371 ctx.ensure_writable(&resolved)
372 .map_err(|e| ErrorData::invalid_params(e, None))?;
373 let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
374 let ops = crate::tools::ctx_patch::parse_ops(&sub)
375 .map_err(|e| ErrorData::invalid_params(e, None))?;
376 groups.push((resolved, ops));
377 }
378 Ok(groups)
379}
380
381fn group_ops_by_path(
385 ops: &[Value],
386 top_path: Option<&str>,
387) -> Result<Vec<(String, Vec<Value>)>, String> {
388 if ops.is_empty() {
389 return Err("ops[] is empty — provide at least one edit".to_string());
390 }
391 let mut order: Vec<String> = Vec::new();
392 let mut by_path: std::collections::HashMap<String, Vec<Value>> =
393 std::collections::HashMap::new();
394 for (i, op) in ops.iter().enumerate() {
395 let obj = op
396 .as_object()
397 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
398 let raw = obj
399 .get("path")
400 .and_then(Value::as_str)
401 .map(str::to_string)
402 .or_else(|| top_path.map(str::to_string))
403 .ok_or_else(|| {
404 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
405 })?;
406 if !by_path.contains_key(&raw) {
407 order.push(raw.clone());
408 }
409 by_path.entry(raw).or_default().push(op.clone());
410 }
411 Ok(order
412 .into_iter()
413 .map(|p| {
414 let ops = by_path.remove(&p).unwrap_or_default();
415 (p, ops)
416 })
417 .collect())
418}
419
420fn apply_one(
425 ctx: &ToolContext,
426 params: &crate::tools::ctx_patch::PatchParams,
427) -> Result<String, ErrorData> {
428 let path = params.path.clone();
429 tokio::task::block_in_place(|| {
430 let cache_lock = ctx
431 .cache
432 .as_ref()
433 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
434 let rt = tokio::runtime::Handle::current();
435
436 let file_lock = crate::core::path_locks::per_file_lock(&path);
441 let _file_guard = {
442 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
443 loop {
444 if let Ok(guard) = file_lock.try_lock() {
445 break guard;
446 }
447 if std::time::Instant::now() >= deadline {
448 return Err(ErrorData::internal_error(
449 format!(
450 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
451 ),
452 None,
453 ));
454 }
455 std::thread::sleep(std::time::Duration::from_millis(20));
456 }
457 };
458
459 let last_mode = match rt.block_on(tokio::time::timeout(
460 std::time::Duration::from_secs(5),
461 cache_lock.read(),
462 )) {
463 Ok(cache) => cache
464 .get(&path)
465 .map(|e| e.last_mode.clone())
466 .unwrap_or_default(),
467 Err(_) => String::new(),
468 };
469
470 let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
472
473 crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
474
475 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
476 crate::tools::ctx_read::dedup_hook::on_write(&path);
477 match rt.block_on(tokio::time::timeout(
478 std::time::Duration::from_secs(5),
479 cache_lock.write(),
480 )) {
481 Ok(mut cache) => {
482 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
483 }
484 Err(_) => {
485 tracing::warn!(
486 "ctx_patch: cache write-lock timeout (5s) applying post-edit cache effect for {path}"
487 );
488 }
489 }
490 }
491
492 if let Some(session_lock) = ctx.session.as_ref() {
493 let guard = rt.block_on(tokio::time::timeout(
494 std::time::Duration::from_secs(5),
495 session_lock.write(),
496 ));
497 if let Ok(mut session) = guard {
498 session.mark_modified(&path);
499 }
500 }
501
502 Ok(output)
503 })
504}
505
506fn delegate_replace_symbol(
511 args: &Map<String, Value>,
512 ctx: &ToolContext,
513) -> Result<ToolOutput, ErrorData> {
514 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
515 .map_err(|e| ErrorData::invalid_params(e, None))?;
516
517 if get_bool(args, "dry_run").unwrap_or(false) {
518 let name = get_str(args, "name").unwrap_or_default();
519 let path = get_str(args, "path").unwrap_or_default();
520 return Ok(ToolOutput::simple(format!(
521 "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
522 )));
523 }
524
525 let has_path = args.get("path").and_then(Value::as_str).is_some();
529 let abs_path = if has_path {
530 require_resolved_path(ctx, args, "path")?
531 } else {
532 String::new()
533 };
534
535 let args_value = Value::Object(refactor_args);
536 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
537 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
538
539 Ok(ToolOutput {
540 text: result,
541 original_tokens: 0,
542 saved_tokens: 0,
543 mode: Some("replace_symbol".to_string()),
544 path: get_str(args, "path"),
545 changed,
546 shell_outcome: None,
547 content_blocks: None,
548 })
549}
550
551fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
558 let find = get_str(args, "find")
559 .filter(|s| !s.is_empty())
560 .ok_or("replace_all requires non-empty 'find'")?;
561
562 for foreign in ["new_text", "new_string", "old_string"] {
563 if args.contains_key(foreign) {
564 return Err(format!(
565 "replace_all names its replacement 'replace', not '{foreign}' — rename it \
566 (an unrecognized replacement key would silently delete every match)"
567 ));
568 }
569 }
570
571 let replace = args
572 .get("replace")
573 .and_then(Value::as_str)
574 .map(String::from)
575 .ok_or(
576 "replace_all requires 'replace' (the replacement text); pass replace=\"\" \
577 explicitly to delete every match",
578 )?;
579
580 Ok((find, replace))
581}
582
583fn handle_replace_all(
585 args: &Map<String, Value>,
586 ctx: &ToolContext,
587) -> Result<ToolOutput, ErrorData> {
588 let path = require_resolved_path(ctx, args, "path")?;
589 let (find, replace) =
590 resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
591 let dry_run = get_bool(args, "dry_run").unwrap_or(false);
592
593 let content = std::fs::read_to_string(&path)
594 .map_err(|e| ErrorData::internal_error(format!("cannot read {path}: {e}"), None))?;
595
596 let count = content.matches(find.as_str()).count();
597 if count == 0 {
598 return Ok(ToolOutput::simple(format!(
599 "No matches for {find:?} in {path}"
600 )));
601 }
602
603 if dry_run {
604 return Ok(ToolOutput::simple(format!(
605 "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
606 )));
607 }
608
609 let file_lock = crate::core::path_locks::per_file_lock(&path);
610 let _guard = file_lock
611 .lock()
612 .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
613
614 let new_content = content.replace(find.as_str(), &replace);
615 crate::config_io::write_atomic(std::path::Path::new(&path), &new_content)
616 .map_err(|e| ErrorData::internal_error(format!("write failed: {e}"), None))?;
617
618 if let Some(cache) = ctx.cache.as_ref() {
619 let rt = tokio::runtime::Handle::current();
620 if let Ok(mut c) = rt.block_on(tokio::time::timeout(
621 std::time::Duration::from_secs(2),
622 cache.write(),
623 )) {
624 c.invalidate(&path);
625 }
626 }
627
628 Ok(ToolOutput::simple(format!(
629 "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
630 )))
631}
632
633#[cfg(test)]
634mod replace_all_tests {
635 use super::*;
636 use serde_json::json;
637
638 fn obj(v: Value) -> Map<String, Value> {
639 match v {
640 Value::Object(m) => m,
641 _ => panic!("expected object"),
642 }
643 }
644
645 #[test]
646 fn resolves_find_and_replace() {
647 let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
648 assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
649 }
650
651 #[test]
652 fn explicit_empty_replace_is_a_deletion() {
653 let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
654 assert_eq!(r, "");
655 }
656
657 #[test]
658 fn missing_replace_is_rejected_not_silent_delete() {
659 let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
660 assert!(err.contains("requires 'replace'"), "got: {err}");
661 }
662
663 #[test]
664 fn foreign_replacement_key_is_rejected() {
665 for key in ["new_string", "new_text", "old_string"] {
666 let err = resolve_find_replace(&obj(json!({"find": "a", key: "b"}))).unwrap_err();
667 assert!(
668 err.contains(key),
669 "must name the offending key {key}: {err}"
670 );
671 }
672 }
673
674 #[test]
675 fn empty_find_is_rejected() {
676 let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
677 assert!(err.contains("find"), "got: {err}");
678 }
679}
680
681#[cfg(test)]
682mod batch_grouping_tests {
683 use super::*;
684 use serde_json::json;
685
686 #[test]
689 fn groups_ops_across_files_preserving_order() {
690 let ops = vec![
691 json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
692 json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
693 json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
694 ];
695 let g = group_ops_by_path(&ops, None).unwrap();
696 assert_eq!(g.len(), 2);
697 assert_eq!(g[0].0, "a.go");
698 assert_eq!(g[0].1.len(), 2);
699 assert_eq!(g[1].0, "b.go");
700 assert_eq!(g[1].1.len(), 1);
701 }
702
703 #[test]
704 fn ops_without_path_fall_back_to_top_level() {
705 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
706 let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
707 assert_eq!(g.len(), 1);
708 assert_eq!(g[0].0, "top.go");
709 }
710
711 #[test]
712 fn op_without_path_and_no_top_level_is_rejected() {
713 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
714 let err = group_ops_by_path(&ops, None).unwrap_err();
715 assert!(err.contains("path"), "got: {err}");
716 }
717
718 #[test]
719 fn empty_ops_rejected() {
720 let err = group_ops_by_path(&[], None).unwrap_err();
721 assert!(err.contains("empty"), "got: {err}");
722 }
723
724 #[test]
725 fn cross_file_rejects_single_value_preimage_and_backup_options() {
726 for key in ["expected_md5", "backup_path"] {
727 let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
728 assert!(validate_cross_file_options(&args, 2).is_err());
729 assert!(validate_cross_file_options(&args, 1).is_ok());
730 }
731 }
732
733 #[test]
734 fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
735 let args = Map::from_iter([
736 ("path".into(), json!("a.rs")),
737 ("op".into(), json!("replace_unique")),
738 ("old_text".into(), json!("old")),
739 ("new_text".into(), json!("new")),
740 ]);
741 let mapped = build_unique_edit_args(&args).expect("valid mapping");
742 assert_eq!(mapped.get("old_string"), Some(&json!("old")));
743 assert_eq!(mapped.get("new_string"), Some(&json!("new")));
744 assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
745 assert!(!mapped.contains_key("op"));
746 assert!(!mapped.contains_key("old_text"));
747 }
748
749 #[test]
750 fn replace_unique_requires_explicit_old_and_new_text() {
751 assert!(build_unique_edit_args(&Map::new()).is_err());
752 let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
753 assert!(build_unique_edit_args(&only_old).is_err());
754 }
755
756 #[test]
757 fn dry_run_replace_unique_does_not_apply() {
758 let args = Map::from_iter([
759 ("path".into(), json!("a.rs")),
760 ("op".into(), json!("replace_unique")),
761 ("old_text".into(), json!("old")),
762 ("new_text".into(), json!("new")),
763 ("dry_run".into(), json!(true)),
764 ]);
765 let edit_args = build_unique_edit_args(&args).expect("validation passes");
766 assert!(
767 edit_args.contains_key("old_string"),
768 "args mapped correctly"
769 );
770 assert!(
771 args.get("dry_run").and_then(Value::as_bool) == Some(true),
772 "dry_run flag preserved in original args"
773 );
774 }
775}