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 for (i, v) in arr.iter().enumerate() {
99 if let Some(kind) = is_unbatchable_op(v) {
100 return Err(ErrorData::invalid_params(
101 format!(
102 "ops[{i}]: {kind} cannot be batched in ops[] \
103 — send it as a separate top-level ctx_patch call"
104 ),
105 None,
106 ));
107 }
108 }
109 if arr.iter().any(|v| delegated_op_kind(v).is_some()) {
110 return handle_mixed_batch(args, arr, ctx);
111 }
112 }
113
114 handle_anchored(args, ctx)
115 }
116}
117
118fn handle_anchored(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
122 if get_bool(args, "dry_run").unwrap_or(false) {
123 let path = get_str(args, "path").unwrap_or_default();
124 return Ok(ToolOutput::simple(format!(
125 "DRY RUN: ctx_patch would apply anchor-based ops to {path}"
126 )));
127 }
128
129 let expected_md5 = get_str(args, "expected_md5");
130 let backup = get_bool(args, "backup").unwrap_or(false);
131 let backup_path = get_str(args, "backup_path")
132 .map(|p| ctx.resolved_paths.get("backup_path").cloned().unwrap_or(p));
133 let evidence = get_bool(args, "evidence").unwrap_or(true);
134 let diff_max_lines = get_int(args, "diff_max_lines")
135 .and_then(|v| usize::try_from(v.max(0)).ok())
136 .unwrap_or(200);
137 let allow_lossy_utf8 = get_bool(args, "allow_lossy_utf8").unwrap_or(false);
138 let validate_syntax = get_bool(args, "validate_syntax").unwrap_or(true);
139
140 let groups = plan_groups(args, ctx)?;
144 validate_cross_file_options(args, groups.len())?;
145 let output_path = (groups.len() == 1).then(|| groups[0].0.clone());
146
147 let mut texts = Vec::with_capacity(groups.len());
148 for (path, ops) in groups {
149 let patch_params = crate::tools::ctx_patch::PatchParams {
150 path: path.clone(),
151 ops,
152 expected_md5: expected_md5.clone(),
153 backup,
154 backup_path: backup_path.clone(),
155 evidence,
156 diff_max_lines,
157 allow_lossy_utf8,
158 validate_syntax,
159 };
160 let output = apply_one(ctx, &patch_params)?;
161 texts.push(format!("[{path}]\n{output}"));
162 }
163
164 Ok(ToolOutput {
165 text: texts.join("\n\n"),
166 original_tokens: 0,
167 saved_tokens: 0,
168 mode: None,
169 path: output_path,
170 changed: false,
171 shell_outcome: None,
172 content_blocks: None,
173 })
174}
175
176fn delegated_op_kind(v: &Value) -> Option<&str> {
179 v.get("op")
180 .and_then(Value::as_str)
181 .filter(|k| matches!(*k, "replace_unique" | "replace_symbol"))
182}
183
184fn is_unbatchable_op(v: &Value) -> Option<&str> {
192 v.get("op")
193 .and_then(Value::as_str)
194 .filter(|k| matches!(*k, "replace_all" | "create"))
195}
196
197fn handle_mixed_batch(
198 args: &Map<String, Value>,
199 arr: &[Value],
200 ctx: &ToolContext,
201) -> Result<ToolOutput, ErrorData> {
202 for (i, v) in arr.iter().enumerate() {
204 if !v.is_object() {
205 return Err(ErrorData::invalid_params(
206 format!("ops[{i}] must be an object"),
207 None,
208 ));
209 }
210 if let Some(kind) = is_unbatchable_op(v) {
211 return Err(ErrorData::invalid_params(
212 format!(
213 "ops[{i}]: {kind} cannot be batched in ops[] \
214 — send it as a separate top-level ctx_patch call"
215 ),
216 None,
217 ));
218 }
219 }
220
221 let mut texts: Vec<String> = Vec::new();
222 let mut run: Vec<Value> = Vec::new();
223 for (i, v) in arr.iter().enumerate() {
224 let obj = v.as_object().ok_or_else(|| {
225 ErrorData::invalid_params(format!("ops[{i}] must be an object"), None)
226 })?;
227 let Some(kind) = delegated_op_kind(v) else {
228 run.push(v.clone());
229 continue;
230 };
231 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
232
233 let (sub_args, sub_ctx) = delegated_op_call(args, obj, ctx, i, kind)?;
234 let out = if kind == "replace_unique" {
235 delegate_replace_unique(&sub_args, &sub_ctx)
236 } else {
237 delegate_replace_symbol(&sub_args, &sub_ctx)
238 }
239 .map_err(|e| {
240 let applied = if texts.is_empty() {
241 ""
242 } else {
243 " (earlier ops in this batch were already applied)"
244 };
245 ErrorData::invalid_params(format!("ops[{i}] ({kind}): {}{applied}", e.message), None)
246 })?;
247 let label = get_str(&sub_args, "path").unwrap_or_else(|| kind.to_string());
248 texts.push(format!("[{label}]\n{}", out.text));
249 }
250 flush_anchored_run(args, ctx, &mut run, &mut texts)?;
251
252 Ok(ToolOutput::simple(texts.join("\n\n")))
253}
254
255fn flush_anchored_run(
257 args: &Map<String, Value>,
258 ctx: &ToolContext,
259 run: &mut Vec<Value>,
260 texts: &mut Vec<String>,
261) -> Result<(), ErrorData> {
262 if run.is_empty() {
263 return Ok(());
264 }
265 let mut sub = args.clone();
266 sub.insert("ops".into(), Value::Array(std::mem::take(run)));
267 texts.push(handle_anchored(&sub, ctx)?.text);
268 Ok(())
269}
270
271fn delegated_op_call(
276 args: &Map<String, Value>,
277 op: &Map<String, Value>,
278 ctx: &ToolContext,
279 i: usize,
280 kind: &str,
281) -> Result<(Map<String, Value>, ToolContext), ErrorData> {
282 let mut sub = op.clone();
283 for key in ["path", "dry_run"] {
284 if !sub.contains_key(key)
285 && let Some(v) = args.get(key)
286 {
287 sub.insert(key.to_string(), v.clone());
288 }
289 }
290
291 let mut sub_ctx = ctx.clone();
292 sub_ctx.resolved_paths.remove("path");
293 sub_ctx.path_errors.remove("path");
294 match get_str(&sub, "path") {
295 Some(raw) => {
296 let resolved = sub_ctx
297 .resolve_path_sync(&raw)
298 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: path: {e}"), None))?;
299 sub_ctx
300 .ensure_writable(&resolved)
301 .map_err(|e| ErrorData::invalid_params(format!("ops[{i}]: {e}"), None))?;
302 sub_ctx.resolved_paths.insert("path".to_string(), resolved);
303 }
304 None if kind == "replace_unique" => {
307 return Err(ErrorData::invalid_params(
308 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)"),
309 None,
310 ));
311 }
312 None => {}
313 }
314 Ok((sub, sub_ctx))
315}
316
317fn delegate_replace_unique(
321 args: &Map<String, Value>,
322 ctx: &ToolContext,
323) -> Result<ToolOutput, ErrorData> {
324 let edit_args =
325 build_unique_edit_args(args).map_err(|message| ErrorData::invalid_params(message, None))?;
326
327 if get_bool(args, "dry_run").unwrap_or(false) {
328 let old_text = get_str(args, "old_text")
329 .or_else(|| get_str(args, "old_string"))
330 .unwrap_or_default();
331 let new_text = get_str(args, "new_text")
332 .or_else(|| get_str(args, "new_string"))
333 .unwrap_or_default();
334 let path = get_str(args, "path").unwrap_or_default();
335 return Ok(ToolOutput::simple(format!(
336 "DRY RUN: replace_unique would replace {old_text:?} with {new_text:?} in {path}"
337 )));
338 }
339
340 crate::tools::registered::ctx_edit::CtxEditTool.handle(&edit_args, ctx)
341}
342
343fn build_unique_edit_args(args: &Map<String, Value>) -> Result<Map<String, Value>, String> {
344 let old_text = get_str(args, "old_text")
345 .or_else(|| get_str(args, "old_string"))
346 .filter(|text| !text.is_empty())
347 .ok_or_else(|| {
348 "replace_unique requires non-empty old_text (old_string also accepted)".to_string()
349 })?;
350 let new_text = get_str(args, "new_text")
351 .or_else(|| get_str(args, "new_string"))
352 .ok_or_else(|| "replace_unique requires new_text (new_string also accepted)".to_string())?;
353
354 let mut edit_args = args.clone();
355 edit_args.insert("old_string".into(), Value::String(old_text));
356 edit_args.insert("new_string".into(), Value::String(new_text));
357 edit_args.insert("replace_all".into(), Value::Bool(false));
358 edit_args.remove("old_text");
359 edit_args.remove("op");
360 Ok(edit_args)
361}
362
363fn validate_cross_file_options(
367 args: &Map<String, Value>,
368 file_count: usize,
369) -> Result<(), ErrorData> {
370 if file_count <= 1 {
371 return Ok(());
372 }
373 for key in ["expected_md5", "backup_path"] {
374 if args.contains_key(key) {
375 return Err(ErrorData::invalid_params(
376 format!("cross-file ctx_patch batches do not support top-level '{key}'"),
377 None,
378 ));
379 }
380 }
381 Ok(())
382}
383
384fn plan_groups(
389 args: &Map<String, Value>,
390 ctx: &ToolContext,
391) -> Result<Vec<(String, Vec<crate::tools::ctx_patch::AnchorOp>)>, ErrorData> {
392 let Some(ops_val) = args.get("ops") else {
393 let path = require_resolved_path(ctx, args, "path")?;
394 let ops = crate::tools::ctx_patch::parse_ops(args)
395 .map_err(|e| ErrorData::invalid_params(e, None))?;
396 return Ok(vec![(path, ops)]);
397 };
398
399 let arr = ops_val
400 .as_array()
401 .ok_or_else(|| ErrorData::invalid_params("ops must be an array of edit objects", None))?;
402 let grouped = group_ops_by_path(arr, get_str(args, "path").as_deref())
403 .map_err(|e| ErrorData::invalid_params(e, None))?;
404
405 let mut groups = Vec::with_capacity(grouped.len());
406 for (raw_path, op_objs) in grouped {
407 let resolved = ctx
408 .resolve_path_sync(&raw_path)
409 .map_err(|e| ErrorData::invalid_params(format!("path: {e}"), None))?;
410 ctx.ensure_writable(&resolved)
411 .map_err(|e| ErrorData::invalid_params(e, None))?;
412 let sub = Map::from_iter([("ops".to_string(), Value::Array(op_objs))]);
413 let ops = crate::tools::ctx_patch::parse_ops(&sub)
414 .map_err(|e| ErrorData::invalid_params(e, None))?;
415 groups.push((resolved, ops));
416 }
417 Ok(groups)
418}
419
420fn group_ops_by_path(
424 ops: &[Value],
425 top_path: Option<&str>,
426) -> Result<Vec<(String, Vec<Value>)>, String> {
427 if ops.is_empty() {
428 return Err("ops[] is empty — provide at least one edit".to_string());
429 }
430 let mut order: Vec<String> = Vec::new();
431 let mut by_path: std::collections::HashMap<String, Vec<Value>> =
432 std::collections::HashMap::new();
433 for (i, op) in ops.iter().enumerate() {
434 let obj = op
435 .as_object()
436 .ok_or_else(|| format!("ops[{i}] must be an object"))?;
437 let raw = obj
438 .get("path")
439 .and_then(Value::as_str)
440 .map(str::to_string)
441 .or_else(|| top_path.map(str::to_string))
442 .ok_or_else(|| {
443 format!("ops[{i}] needs its own 'path' (no top-level 'path' to fall back to)")
444 })?;
445 if !by_path.contains_key(&raw) {
446 order.push(raw.clone());
447 }
448 by_path.entry(raw).or_default().push(op.clone());
449 }
450 Ok(order
451 .into_iter()
452 .map(|p| {
453 let ops = by_path.remove(&p).unwrap_or_default();
454 (p, ops)
455 })
456 .collect())
457}
458
459fn apply_one(
464 ctx: &ToolContext,
465 params: &crate::tools::ctx_patch::PatchParams,
466) -> Result<String, ErrorData> {
467 let path = params.path.clone();
468 {
469 let cache_lock = ctx
470 .cache
471 .as_ref()
472 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
473
474 let file_lock = crate::core::path_locks::per_file_lock(&path);
479 let _file_guard = {
480 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
481 loop {
482 if let Ok(guard) = file_lock.try_lock() {
483 break guard;
484 }
485 if std::time::Instant::now() >= deadline {
486 return Err(ErrorData::internal_error(
487 format!(
488 "per-file edit lock contention for {path} — another edit to the same file is in progress, retry in a moment"
489 ),
490 None,
491 ));
492 }
493 std::thread::sleep(std::time::Duration::from_millis(20));
494 }
495 };
496
497 let last_mode = match crate::server::bounded_lock::read(cache_lock, "ctx_patch cache read")
498 {
499 Some(cache) => cache
500 .get(&path)
501 .map(|e| e.last_mode.clone())
502 .unwrap_or_default(),
503 None => String::new(),
504 };
505
506 let (output, effect) = crate::tools::ctx_patch::run_io(params, &last_mode);
508
509 crate::tools::ctx_patch::record_outcome(params, &last_mode, &output, &effect);
510
511 if !matches!(effect, crate::tools::ctx_edit::CacheEffect::None) {
512 crate::tools::ctx_read::dedup_hook::on_write(&path);
513 match crate::server::bounded_lock::write(cache_lock, "ctx_patch cache write") {
514 Some(mut cache) => {
515 crate::tools::ctx_edit::apply_cache_effect(&mut cache, &path, effect);
516 }
517 None => {
518 tracing::warn!(
519 "ctx_patch: cache write-lock timeout applying effect for {path}"
520 );
521 }
522 }
523 }
524
525 if let Some(session_lock) = ctx.session.as_ref() {
526 if let Some(mut session) =
527 crate::server::bounded_lock::write(session_lock, "ctx_patch session write")
528 {
529 session.mark_modified(&path);
530 }
531 }
532
533 Ok(output)
534 }
535}
536
537fn delegate_replace_symbol(
542 args: &Map<String, Value>,
543 ctx: &ToolContext,
544) -> Result<ToolOutput, ErrorData> {
545 let refactor_args = crate::tools::ctx_patch::build_refactor_args(args)
546 .map_err(|e| ErrorData::invalid_params(e, None))?;
547
548 if get_bool(args, "dry_run").unwrap_or(false) {
549 let name = get_str(args, "name").unwrap_or_default();
550 let path = get_str(args, "path").unwrap_or_default();
551 return Ok(ToolOutput::simple(format!(
552 "DRY RUN: replace_symbol would rewrite symbol {name:?} in {path}"
553 )));
554 }
555
556 let has_path = args.get("path").and_then(Value::as_str).is_some();
560 let abs_path = if has_path {
561 require_resolved_path(ctx, args, "path")?
562 } else {
563 String::new()
564 };
565
566 let args_value = Value::Object(refactor_args);
567 let result = crate::tools::ctx_refactor::handle(&args_value, &ctx.project_root, &abs_path);
568 let changed = !result.starts_with("ERROR") && !result.starts_with("CONFLICT");
569
570 Ok(ToolOutput {
571 text: result,
572 original_tokens: 0,
573 saved_tokens: 0,
574 mode: Some("replace_symbol".to_string()),
575 path: get_str(args, "path"),
576 changed,
577 shell_outcome: None,
578 content_blocks: None,
579 })
580}
581
582fn resolve_find_replace(args: &Map<String, Value>) -> Result<(String, String), String> {
589 let find = get_str(args, "find")
590 .filter(|s| !s.is_empty())
591 .ok_or("replace_all requires non-empty 'find'")?;
592
593 for foreign in ["new_text", "new_string", "old_string"] {
594 if args.contains_key(foreign) {
595 return Err(format!(
596 "replace_all names its replacement 'replace', not '{foreign}' — rename it \
597 (an unrecognized replacement key would silently delete every match)"
598 ));
599 }
600 }
601
602 let replace = args
603 .get("replace")
604 .and_then(Value::as_str)
605 .map(String::from)
606 .ok_or(
607 "replace_all requires 'replace' (the replacement text); pass replace=\"\" \
608 explicitly to delete every match",
609 )?;
610
611 Ok((find, replace))
612}
613
614fn handle_replace_all(
616 args: &Map<String, Value>,
617 ctx: &ToolContext,
618) -> Result<ToolOutput, ErrorData> {
619 let path = require_resolved_path(ctx, args, "path")?;
620 let (find, replace) =
621 resolve_find_replace(args).map_err(|e| ErrorData::invalid_params(e, None))?;
622 let dry_run = get_bool(args, "dry_run").unwrap_or(false);
623
624 let content = std::fs::read_to_string(&path)
625 .map_err(|e| ErrorData::internal_error(format!("cannot read {path}: {e}"), None))?;
626
627 let count = content.matches(find.as_str()).count();
628 if count == 0 {
629 return Ok(ToolOutput::simple(format!(
630 "No matches for {find:?} in {path}"
631 )));
632 }
633
634 if dry_run {
635 return Ok(ToolOutput::simple(format!(
636 "DRY RUN: {count} occurrence(s) of {find:?} would be replaced with {replace:?} in {path}"
637 )));
638 }
639
640 let file_lock = crate::core::path_locks::per_file_lock(&path);
641 let _guard = file_lock
642 .lock()
643 .map_err(|_| ErrorData::internal_error(format!("lock contention for {path}"), None))?;
644
645 let new_content = content.replace(find.as_str(), &replace);
646 crate::config_io::write_atomic(std::path::Path::new(&path), &new_content)
647 .map_err(|e| ErrorData::internal_error(format!("write failed: {e}"), None))?;
648
649 if let Some(cache) = ctx.cache.as_ref() {
650 if let Some(mut c) =
651 crate::server::bounded_lock::write(cache, "ctx_patch replace_all cache invalidate")
652 {
653 c.invalidate(&path);
654 }
655 }
656
657 Ok(ToolOutput::simple(format!(
658 "Replaced {count} occurrence(s) of {find:?} with {replace:?} in {path}"
659 )))
660}
661
662#[cfg(test)]
663mod replace_all_tests {
664 use super::*;
665 use serde_json::json;
666
667 fn obj(v: Value) -> Map<String, Value> {
668 match v {
669 Value::Object(m) => m,
670 _ => panic!("expected object"),
671 }
672 }
673
674 #[test]
675 fn resolves_find_and_replace() {
676 let (f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": "b"}))).unwrap();
677 assert_eq!((f.as_str(), r.as_str()), ("a", "b"));
678 }
679
680 #[test]
681 fn explicit_empty_replace_is_a_deletion() {
682 let (_f, r) = resolve_find_replace(&obj(json!({"find": "a", "replace": ""}))).unwrap();
683 assert_eq!(r, "");
684 }
685
686 #[test]
687 fn missing_replace_is_rejected_not_silent_delete() {
688 let err = resolve_find_replace(&obj(json!({"find": "a"}))).unwrap_err();
689 assert!(err.contains("requires 'replace'"), "got: {err}");
690 }
691
692 #[test]
693 fn foreign_replacement_key_is_rejected() {
694 for key in ["new_string", "new_text", "old_string"] {
695 let err = resolve_find_replace(&obj(json!({"find": "a", key: "b"}))).unwrap_err();
696 assert!(
697 err.contains(key),
698 "must name the offending key {key}: {err}"
699 );
700 }
701 }
702
703 #[test]
704 fn empty_find_is_rejected() {
705 let err = resolve_find_replace(&obj(json!({"find": "", "replace": "b"}))).unwrap_err();
706 assert!(err.contains("find"), "got: {err}");
707 }
708}
709
710#[cfg(test)]
711mod batch_grouping_tests {
712 use super::*;
713 use serde_json::json;
714
715 #[test]
718 fn groups_ops_across_files_preserving_order() {
719 let ops = vec![
720 json!({"op":"insert_after","path":"a.go","line":1,"hash":"aa","new_text":"x"}),
721 json!({"op":"insert_after","path":"b.go","line":2,"hash":"bb","new_text":"y"}),
722 json!({"op":"set_line","path":"a.go","line":3,"hash":"cc","new_text":"z"}),
723 ];
724 let g = group_ops_by_path(&ops, None).unwrap();
725 assert_eq!(g.len(), 2);
726 assert_eq!(g[0].0, "a.go");
727 assert_eq!(g[0].1.len(), 2);
728 assert_eq!(g[1].0, "b.go");
729 assert_eq!(g[1].1.len(), 1);
730 }
731
732 #[test]
733 fn ops_without_path_fall_back_to_top_level() {
734 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
735 let g = group_ops_by_path(&ops, Some("top.go")).unwrap();
736 assert_eq!(g.len(), 1);
737 assert_eq!(g[0].0, "top.go");
738 }
739
740 #[test]
741 fn op_without_path_and_no_top_level_is_rejected() {
742 let ops = vec![json!({"op":"set_line","line":1,"hash":"aa","new_text":"x"})];
743 let err = group_ops_by_path(&ops, None).unwrap_err();
744 assert!(err.contains("path"), "got: {err}");
745 }
746
747 #[test]
748 fn empty_ops_rejected() {
749 let err = group_ops_by_path(&[], None).unwrap_err();
750 assert!(err.contains("empty"), "got: {err}");
751 }
752
753 #[test]
754 fn cross_file_rejects_single_value_preimage_and_backup_options() {
755 for key in ["expected_md5", "backup_path"] {
756 let args = Map::from_iter([(key.to_string(), json!("one-value"))]);
757 assert!(validate_cross_file_options(&args, 2).is_err());
758 assert!(validate_cross_file_options(&args, 1).is_ok());
759 }
760 }
761
762 #[test]
763 fn replace_unique_maps_to_a_single_safe_ctx_edit_replacement() {
764 let args = Map::from_iter([
765 ("path".into(), json!("a.rs")),
766 ("op".into(), json!("replace_unique")),
767 ("old_text".into(), json!("old")),
768 ("new_text".into(), json!("new")),
769 ]);
770 let mapped = build_unique_edit_args(&args).expect("valid mapping");
771 assert_eq!(mapped.get("old_string"), Some(&json!("old")));
772 assert_eq!(mapped.get("new_string"), Some(&json!("new")));
773 assert_eq!(mapped.get("replace_all"), Some(&json!(false)));
774 assert!(!mapped.contains_key("op"));
775 assert!(!mapped.contains_key("old_text"));
776 }
777
778 #[test]
779 fn replace_unique_requires_explicit_old_and_new_text() {
780 assert!(build_unique_edit_args(&Map::new()).is_err());
781 let only_old = Map::from_iter([("old_text".into(), json!("old"))]);
782 assert!(build_unique_edit_args(&only_old).is_err());
783 }
784
785 #[test]
786 fn dry_run_replace_unique_does_not_apply() {
787 let args = Map::from_iter([
788 ("path".into(), json!("a.rs")),
789 ("op".into(), json!("replace_unique")),
790 ("old_text".into(), json!("old")),
791 ("new_text".into(), json!("new")),
792 ("dry_run".into(), json!(true)),
793 ]);
794 let edit_args = build_unique_edit_args(&args).expect("validation passes");
795 assert!(
796 edit_args.contains_key("old_string"),
797 "args mapped correctly"
798 );
799 assert!(
800 args.get("dry_run").and_then(Value::as_bool) == Some(true),
801 "dry_run flag preserved in original args"
802 );
803 }
804}