1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use cookie_agent_engine::{
5 PreparedExecutor, PreparedSerializationKey, PreparedTool, SessionToolContext, ToolCall,
6 ToolError, ToolExecutionContext, ToolPreparationContext, ToolProvider, ToolSpec,
7};
8use cookie_agent_protocol::{PermissionAction, PersistedToolResult as ToolResult, Sha256Digest};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::{fs_cap, parse_args, prepared_operation, prepared_path_resources, schema};
13
14#[derive(Debug)]
15pub struct EditTool {
16 workspace: PathBuf,
17}
18
19#[derive(Debug, Deserialize, JsonSchema, Serialize)]
20struct EditArgs {
21 #[serde(
22 rename = "filePath",
23 deserialize_with = "crate::path_args::deserialize"
24 )]
25 file_path: String,
27 #[serde(rename = "oldString")]
28 old_string: String,
29 #[serde(rename = "newString")]
30 new_string: String,
31 #[serde(rename = "replaceAll", default)]
32 replace_all: bool,
33}
34
35struct EditExecutor {
36 target: EditTarget,
37 new_bytes: Vec<u8>,
38}
39
40enum EditTarget {
41 Existing(fs_cap::PreparedExisting),
42 Chained(fs_cap::PreparedChained),
43}
44
45impl EditTool {
46 #[must_use]
47 pub fn new(workspace: impl Into<PathBuf>) -> Self {
48 Self {
49 workspace: workspace.into(),
50 }
51 }
52
53 fn prepare_target(
56 &self,
57 ctx: &ToolPreparationContext,
58 call: ToolCall,
59 ) -> Result<PendingEdit, ToolError> {
60 let args: EditArgs = parse_args("edit", call.arguments)?;
61 fs_cap::ensure_atomic_write_supported()?;
62 if args.old_string.is_empty() {
63 return Err(ToolError::execution("oldString must not be empty"));
64 }
65 let target = fs_cap::prepare_existing(&ctx.cwd, std::path::Path::new(&args.file_path))?;
66 if target.directory {
67 return Err(ToolError::unsupported_security(
68 "edit target is a directory",
69 ));
70 }
71 let bytes = target.read_bytes()?;
72 let text =
73 String::from_utf8(bytes).map_err(|_| ToolError::execution("edit requires UTF-8"))?;
74 Ok(PendingEdit { args, target, text })
75 }
76
77 fn prepare_edit(
85 &self,
86 ctx: &ToolPreparationContext,
87 args: EditArgs,
88 target: fs_cap::PreparedExisting,
89 text: &str,
90 predecessor: Option<Sha256Digest>,
91 ) -> Result<(PreparedTool, String), ToolError> {
92 let count = text.matches(&args.old_string).count();
93 if count == 0 {
94 return Err(ToolError::execution("oldString was not found"));
95 }
96 if !args.replace_all && count != 1 {
97 return Err(ToolError::execution(format!(
98 "oldString matched {count} times"
99 )));
100 }
101 let replaced = if args.replace_all {
102 text.replace(&args.old_string, &args.new_string)
103 } else {
104 text.replacen(&args.old_string, &args.new_string, 1)
105 };
106 let new_bytes = replaced.clone().into_bytes();
107 let input_digest = predecessor
108 .clone()
109 .unwrap_or_else(|| target.content_digest.clone());
110 let mut binding = target.identity.canonical_bytes();
111 binding.extend_from_slice(input_digest.as_str().as_bytes());
112 binding.extend_from_slice(
113 Sha256Digest::of_bytes(args.old_string.as_bytes())
114 .as_str()
115 .as_bytes(),
116 );
117 binding.extend_from_slice(
118 Sha256Digest::of_bytes(args.new_string.as_bytes())
119 .as_str()
120 .as_bytes(),
121 );
122 binding.extend_from_slice(&(count as u64).to_be_bytes());
123 binding.extend_from_slice(Sha256Digest::of_bytes(&new_bytes).as_str().as_bytes());
124 binding.extend_from_slice(&target.manifest_bytes()?);
125 let (resources, policy_labels) = prepared_path_resources(
126 PermissionAction::Write,
127 "file",
128 &target.display_path,
129 &self.workspace,
130 &binding,
131 )?;
132 let context = fs_cap::cwd_context_bytes(&ctx.cwd)?;
133 let operation = prepared_operation(
134 "edit",
135 &args,
136 vec![(PermissionAction::Write, "edit")],
137 resources,
138 &context,
139 )?;
140 let normalized_arguments = serde_json::json!({
141 "filePath": target.display_path,
142 "oldString": args.old_string,
143 "newString": args.new_string,
144 "replaceAll": args.replace_all,
145 });
146 let mut serialization_key = target.identity.device.to_be_bytes().to_vec();
147 serialization_key.extend_from_slice(&target.identity.inode.to_be_bytes());
148 let executor_target = match predecessor {
149 Some(predecessor_digest) => EditTarget::Chained(fs_cap::PreparedChained {
150 original_digest: target.content_digest.clone(),
151 predecessor_digest,
152 target,
153 }),
154 None => EditTarget::Existing(target),
155 };
156 let prepared = PreparedTool::new(
157 operation,
158 normalized_arguments,
159 Some(PreparedSerializationKey::new(serialization_key)),
160 Box::new(EditExecutor {
161 target: executor_target,
162 new_bytes,
163 }),
164 )?
165 .with_policy_labels(policy_labels)?;
166 Ok((prepared, replaced))
167 }
168}
169
170struct PendingEdit {
171 args: EditArgs,
172 target: fs_cap::PreparedExisting,
173 text: String,
174}
175impl Default for EditTool {
176 fn default() -> Self {
177 Self::new(std::env::current_dir().expect("current directory"))
178 }
179}
180
181#[async_trait]
182impl ToolProvider for EditTool {
183 fn provider_id(&self) -> &'static str {
184 "builtin.edit"
185 }
186
187 fn tools_for_session(&self, _: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError> {
188 Ok(vec![ToolSpec {
189 output: Default::default(),
190 concurrency: cookie_agent_engine::ToolConcurrency::Parallel,
191 result_truncation: Default::default(),
192 name: "edit".into(),
193 permission_name: Self::get_permission_name("edit")?.into(),
194 description: "Apply a precomputed semantic replacement atomically.".into(),
195 parameters: schema::<EditArgs>(),
196 }])
197 }
198
199 fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError> {
200 match tool_name {
201 "edit" => Ok("write"),
202 _ => Err(ToolError::execution("edit provider received another tool")),
203 }
204 }
205
206 fn get_permission_resource(
207 &self,
208 name: &str,
209 arguments: &serde_json::Value,
210 ) -> Result<(&'static str, Option<String>), ToolError> {
211 let permission_name = Self::get_permission_name(name)?;
212 let args: EditArgs = parse_args("edit", arguments.clone())?;
213 if args.file_path.is_empty() {
214 return Err(ToolError::execution("filePath must not be empty"));
215 }
216 Ok((
217 permission_name,
218 Some(crate::permission_path_label(
219 &args.file_path,
220 &self.workspace,
221 )),
222 ))
223 }
224
225 fn get_display_argument(
226 &self,
227 name: &str,
228 arguments: &serde_json::Value,
229 ) -> Result<String, ToolError> {
230 let (_, Some(path)) = self.get_permission_resource(name, arguments)? else {
231 return Err(ToolError::execution("edit permission resource is missing"));
232 };
233 Ok(crate::abbreviated_display_path(&path, &self.workspace))
234 }
235
236 async fn prepare(
237 &self,
238 ctx: ToolPreparationContext,
239 call: ToolCall,
240 ) -> Result<PreparedTool, ToolError> {
241 let args: EditArgs = parse_args("edit", call.arguments)?;
242 fs_cap::ensure_atomic_write_supported()?;
243 if args.old_string.is_empty() {
244 return Err(ToolError::execution("oldString must not be empty"));
245 }
246 let target = fs_cap::prepare_existing(&ctx.cwd, std::path::Path::new(&args.file_path))?;
247 if target.directory {
248 return Err(ToolError::unsupported_security(
249 "edit target is a directory",
250 ));
251 }
252 let bytes = target.read_bytes()?;
253 let text =
254 String::from_utf8(bytes).map_err(|_| ToolError::execution("edit requires UTF-8"))?;
255 let (prepared, _) = self.prepare_edit(&ctx, args, target, &text, None)?;
256 Ok(prepared)
257 }
258
259 async fn prepare_parallel(
260 &self,
261 ctx: ToolPreparationContext,
262 calls: Vec<ToolCall>,
263 ) -> Vec<Result<PreparedTool, ToolError>> {
264 if calls.len() == 1 {
265 let call = calls.into_iter().next().expect("single call batch");
266 return vec![self.prepare(ctx, call).await];
267 }
268 let mut pending = Vec::with_capacity(calls.len());
272 for call in calls {
273 pending.push(Some(self.prepare_target(&ctx, call)));
274 }
275 let mut results: Vec<Option<Result<PreparedTool, ToolError>>> =
276 (0..pending.len()).map(|_| None).collect();
277 let mut groups: Vec<((u64, u64), Vec<usize>)> = Vec::new();
278 for (index, entry) in pending.iter_mut().enumerate() {
279 match entry {
280 Some(Ok(pending_edit)) => {
281 let key = (
282 pending_edit.target.identity.device,
283 pending_edit.target.identity.inode,
284 );
285 if let Some((_, indexes)) =
286 groups.iter_mut().find(|(candidate, _)| *candidate == key)
287 {
288 indexes.push(index);
289 } else {
290 groups.push((key, vec![index]));
291 }
292 }
293 Some(Err(_)) => {
296 let Err(error) = entry.take().expect("each edit call is visited once") else {
297 unreachable!("error entry");
298 };
299 results[index] = Some(Err(error));
300 }
301 None => unreachable!("each edit call is visited once"),
302 }
303 }
304 for (_, indexes) in groups {
305 let base_digest = pending[indexes[0]]
306 .as_ref()
307 .and_then(|entry| entry.as_ref().ok())
308 .expect("grouped edit prepared its target")
309 .target
310 .content_digest
311 .clone();
312 let mut chain_text: Option<String> = None;
318 let mut chain_started = false;
319 for index in indexes {
320 let pending_edit = pending[index]
321 .take()
322 .expect("grouped edit pending")
323 .expect("grouped edit prepared");
324 let chained = pending_edit.target.content_digest == base_digest;
327 let (input_text, predecessor) = if chained {
328 let predecessor = if chain_started {
329 chain_text.as_deref().map(|text| {
330 cookie_agent_protocol::Sha256Digest::of_bytes(text.as_bytes())
331 })
332 } else {
333 None
334 };
335 let text = chain_text
336 .clone()
337 .unwrap_or_else(|| pending_edit.text.clone());
338 (text, predecessor)
339 } else {
340 (pending_edit.text.clone(), None)
341 };
342 match self.prepare_edit(
343 &ctx,
344 pending_edit.args,
345 pending_edit.target,
346 &input_text,
347 predecessor,
348 ) {
349 Ok((prepared, new_text)) => {
350 if chained {
351 chain_text = Some(new_text);
352 chain_started = true;
353 }
354 results[index] = Some(Ok(prepared));
355 }
356 Err(error) => {
357 results[index] = Some(Err(error));
358 }
359 }
360 }
361 }
362 results
363 .into_iter()
364 .map(|result| result.expect("every edit call is grouped"))
365 .collect()
366 }
367}
368
369#[async_trait]
370impl PreparedExecutor for EditExecutor {
371 async fn revalidate(&self) -> Result<(), ToolError> {
372 match &self.target {
373 EditTarget::Existing(target) => target.revalidate(),
374 EditTarget::Chained(target) => target.revalidate(),
375 }
376 }
377 async fn revalidate_for_execution(&self) -> Result<(), ToolError> {
378 match &self.target {
379 EditTarget::Existing(target) => target.revalidate(),
380 EditTarget::Chained(target) => target.revalidate_for_execution(),
381 }
382 }
383
384 async fn execute(
385 self: Box<Self>,
386 context: ToolExecutionContext,
387 ) -> Result<cookie_agent_engine::ToolCompletion, ToolError> {
388 let result: Result<ToolResult, ToolError> = async move {
389 if context.cancellation.is_cancelled() {
390 return Err(ToolError::execution(
391 "prepared edit cancelled before commit",
392 ));
393 }
394 let path = match &self.target {
395 EditTarget::Existing(target) => target.display_path.clone(),
396 EditTarget::Chained(target) => target.target.display_path.clone(),
397 };
398 let outcome = match &self.target {
399 EditTarget::Existing(target) => target.replace_atomically(&self.new_bytes)?,
400 EditTarget::Chained(target) => {
401 target.revalidate_for_execution()?;
404 target.replace_atomically(&self.new_bytes)?
405 }
406 };
407 Ok(ToolResult {
408display: None,
409retained_output: None,
410 title: crate::safe_title(format!("Edited {}", path.display())),
411 output: "Edit applied atomically".into(),
412 metadata: serde_json::json!({"new_sha256":Sha256Digest::of_bytes(&self.new_bytes),"cleanup_warning":outcome.cleanup_warning}),
413 truncation: None,
414 attachments: Vec::new(),
415 additional_messages: Vec::new(),
416 })
417}.await;
418 result.map(cookie_agent_engine::ToolCompletion::single)
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use std::fs;
425
426 #[cfg(any(unix, windows))]
427 use cookie_agent_engine::ToolExecutionContext;
428 use cookie_agent_engine::{ToolCall, ToolError, ToolPreparationContext, ToolProvider};
429 #[cfg(any(unix, windows))]
430 use cookie_agent_protocol::OperationFingerprint;
431 use cookie_agent_protocol::{RunId, SessionId, ToolCallId};
432
433 use super::EditTool;
434
435 #[test]
436 fn permission_resource_is_the_file_path() {
437 let tool = EditTool::new("/tmp");
438 assert_eq!(
439 tool.get_permission_resource(
440 "edit",
441 &serde_json::json!({
442 "filePath":"value.txt",
443 "oldString":"a",
444 "newString":"b"
445 })
446 )
447 .expect("permission resource"),
448 ("write", Some("value.txt".into()))
449 );
450 assert!(matches!(
451 tool.get_permission_resource(
452 "edit",
453 &serde_json::json!({"oldString":"a","newString":"b"})
454 ),
455 Err(ToolError::Failed(_))
456 ));
457 }
458
459 #[test]
460 fn display_argument_abbreviates_workspace_and_home_paths() {
461 let workspace = tempfile::tempdir().expect("workspace");
462 let tool = EditTool::new(workspace.path());
463 let args = serde_json::json!({
464 "filePath":workspace.path().join("value.txt"),
465 "oldString":"a",
466 "newString":"b"
467 });
468 assert_eq!(
469 tool.get_display_argument("edit", &args).expect("workspace"),
470 "value.txt"
471 );
472 let home = cookie_agent_protocol::paths::home_dir().expect("home directory");
473 assert_eq!(
474 tool.get_display_argument(
475 "edit",
476 &serde_json::json!({
477 "filePath": home.join("value.txt"),
478 "oldString":"a",
479 "newString":"b"
480 })
481 )
482 .expect("home"),
483 "~/value.txt"
484 );
485 }
486
487 #[tokio::test]
488 async fn ambiguous_single_replacement_is_rejected_before_approval() {
489 let root = tempfile::tempdir().expect("root");
490 fs::write(root.path().join("value.txt"), "same same").expect("fixture");
491 let result = EditTool::new(root.path())
492 .prepare(
493 ToolPreparationContext {
494 session: SessionId::new_v7(),
495 run: RunId::new_v7(),
496 cwd: root.path().to_owned(),
497 workspace_root: root.path().to_owned(),
498 turn_context: crate::test_turn_context(),
499 },
500 ToolCall {
501 id: ToolCallId::new_v7(),
502 name: "edit".into(),
503 arguments: serde_json::json!({
504 "filePath":"value.txt",
505 "oldString":"same",
506 "newString":"new"
507 }),
508 },
509 )
510 .await;
511 assert!(matches!(result, Err(ToolError::Failed(_))));
512 }
513
514 #[cfg(any(unix, windows))]
515 fn context(root: &std::path::Path) -> ToolPreparationContext {
516 ToolPreparationContext {
517 session: SessionId::new_v7(),
518 run: RunId::new_v7(),
519 cwd: root.to_owned(),
520 workspace_root: root.to_owned(),
521 turn_context: crate::test_turn_context(),
522 }
523 }
524
525 #[cfg(unix)]
526 #[tokio::test]
527 async fn symlink_routes_keep_lexical_edit_contract_and_preserve_links() {
528 use std::os::unix::fs::symlink;
529
530 let root = tempfile::tempdir().expect("root");
531 let workspace = root.path().join("workspace");
532 let external = root.path().join("external");
533 fs::create_dir(&workspace).expect("workspace");
534 fs::create_dir(workspace.join("routes")).expect("routes");
535 fs::create_dir(&external).expect("external");
536 for name in [
537 "relative-leaf.txt",
538 "absolute-leaf.txt",
539 "relative-ancestor.txt",
540 "absolute-ancestor.txt",
541 ] {
542 fs::write(external.join(name), "old value").expect("fixture");
543 }
544 symlink(
545 "../../external/relative-leaf.txt",
546 workspace.join("routes/relative-leaf"),
547 )
548 .expect("relative leaf");
549 symlink(
550 external.join("absolute-leaf.txt"),
551 workspace.join("routes/absolute-leaf"),
552 )
553 .expect("absolute leaf");
554 symlink(
555 "absolute-ancestor",
556 workspace.join("routes/relative-ancestor"),
557 )
558 .expect("relative ancestor");
559 symlink(&external, workspace.join("routes/absolute-ancestor")).expect("absolute ancestor");
560
561 let tool = EditTool::new(&workspace);
562 for (requested, destination, link) in [
563 (
564 "routes/relative-leaf",
565 external.join("relative-leaf.txt"),
566 workspace.join("routes/relative-leaf"),
567 ),
568 (
569 "routes/absolute-leaf",
570 external.join("absolute-leaf.txt"),
571 workspace.join("routes/absolute-leaf"),
572 ),
573 (
574 "routes/relative-ancestor/relative-ancestor.txt",
575 external.join("relative-ancestor.txt"),
576 workspace.join("routes/relative-ancestor"),
577 ),
578 (
579 "routes/absolute-ancestor/absolute-ancestor.txt",
580 external.join("absolute-ancestor.txt"),
581 workspace.join("routes/absolute-ancestor"),
582 ),
583 ] {
584 let original_link = fs::read_link(&link).expect("link target");
585 let prepared = tool
586 .prepare(
587 context(&workspace),
588 ToolCall {
589 id: ToolCallId::new_v7(),
590 name: "edit".into(),
591 arguments: serde_json::json!({
592 "filePath":requested,
593 "oldString":"old",
594 "newString":"new"
595 }),
596 },
597 )
598 .await
599 .expect("prepare symlink edit");
600 assert_eq!(prepared.operation().resources().len(), 1);
601 assert_eq!(prepared.policy_labels(), [Some(requested.to_owned())]);
602 assert_eq!(
603 prepared.normalized_arguments()["filePath"],
604 workspace.join(requested).to_string_lossy().as_ref()
605 );
606 assert_eq!(
607 tool.get_permission_resource("edit", prepared.normalized_arguments())
608 .expect("normalized permission resource"),
609 ("write", Some(requested.to_owned()))
610 );
611 crate::assert_workspace_rule_allows(
612 &prepared,
613 &workspace,
614 cookie_agent_protocol::PermissionAction::Write,
615 "routes/*",
616 );
617 prepared
618 .execute_for_test(
619 ToolExecutionContext::for_test(
620 root.path().join("artifacts"),
621 crate::test_turn_context(),
622 )
623 .expect("execution context"),
624 )
625 .await
626 .expect("execute symlink edit");
627 assert_eq!(fs::read_to_string(destination).unwrap(), "new value");
628 assert_eq!(fs::read_link(link).unwrap(), original_link);
629 }
630 }
631
632 #[cfg(unix)]
633 #[tokio::test]
634 async fn dangling_edit_errors_and_ancestor_swap_is_operation_changed() {
635 use std::os::unix::fs::symlink;
636
637 let root = tempfile::tempdir().expect("root");
638 fs::create_dir(root.path().join("destination")).expect("destination");
639 fs::write(root.path().join("destination/value.txt"), "old").expect("fixture");
640 symlink("missing.txt", root.path().join("dangling")).expect("dangling route");
641 let dangling = EditTool::new(root.path())
642 .prepare(
643 context(root.path()),
644 ToolCall {
645 id: ToolCallId::new_v7(),
646 name: "edit".into(),
647 arguments: serde_json::json!({
648 "filePath":"dangling",
649 "oldString":"old",
650 "newString":"new"
651 }),
652 },
653 )
654 .await;
655 assert!(matches!(dangling, Err(ToolError::Failed(_))));
656
657 symlink("destination", root.path().join("ancestor")).expect("ancestor route");
658 let prepared = EditTool::new(root.path())
659 .prepare(
660 context(root.path()),
661 ToolCall {
662 id: ToolCallId::new_v7(),
663 name: "edit".into(),
664 arguments: serde_json::json!({
665 "filePath":"ancestor/value.txt",
666 "oldString":"old",
667 "newString":"new"
668 }),
669 },
670 )
671 .await
672 .expect("prepare edit");
673 fs::remove_file(root.path().join("ancestor")).expect("remove ancestor");
674 symlink("destination", root.path().join("ancestor")).expect("replace ancestor");
675 let error = prepared
676 .execute_for_test(
677 ToolExecutionContext::for_test(
678 root.path().join("artifacts"),
679 crate::test_turn_context(),
680 )
681 .expect("execution context"),
682 )
683 .await
684 .expect_err("ancestor swap must fail");
685 assert!(matches!(error, ToolError::OperationChanged(_)));
686 }
687
688 #[cfg(unix)]
689 #[tokio::test]
690 async fn edit_routes_to_the_same_target_have_distinct_fingerprints() {
691 use std::os::unix::fs::symlink;
692
693 let root = tempfile::tempdir().expect("root");
694 fs::write(root.path().join("value.txt"), "old").expect("fixture");
695 symlink("value.txt", root.path().join("left")).expect("left route");
696 symlink(root.path().join("value.txt"), root.path().join("right")).expect("right route");
697 let tool = EditTool::new(root.path());
698 let prepare = |path: &str| {
699 tool.prepare(
700 context(root.path()),
701 ToolCall {
702 id: ToolCallId::new_v7(),
703 name: "edit".into(),
704 arguments: serde_json::json!({
705 "filePath":path,
706 "oldString":"old",
707 "newString":"new"
708 }),
709 },
710 )
711 };
712 let left = prepare("left").await.expect("left");
713 let right = prepare("right").await.expect("right");
714 assert_ne!(
715 left.operation().resources()[0].binding_digest,
716 right.operation().resources()[0].binding_digest
717 );
718 assert_ne!(
719 OperationFingerprint::from_prepared_operation(left.operation()),
720 OperationFingerprint::from_prepared_operation(right.operation())
721 );
722 }
723
724 #[cfg(any(unix, windows))]
725 fn edit_call(path: &str, old: &str, new: &str) -> ToolCall {
726 ToolCall {
727 id: ToolCallId::new_v7(),
728 name: "edit".into(),
729 arguments: serde_json::json!({
730 "filePath": path,
731 "oldString": old,
732 "newString": new
733 }),
734 }
735 }
736
737 #[cfg(any(unix, windows))]
738 fn execution_context(root: &std::path::Path) -> ToolExecutionContext {
739 ToolExecutionContext::for_test(root.join("artifacts"), crate::test_turn_context())
740 .expect("execution context")
741 }
742
743 #[cfg(any(unix, windows))]
744 #[tokio::test]
745 async fn parallel_same_file_edits_chain_in_call_order() {
746 let root = tempfile::tempdir().expect("root");
747 fs::write(root.path().join("value.txt"), "alpha beta gamma").expect("fixture");
748 let prepared = EditTool::new(root.path())
749 .prepare_parallel(
750 context(root.path()),
751 vec![
752 edit_call("value.txt", "alpha", "ALPHA"),
753 edit_call("value.txt", "beta", "BETA"),
754 ],
755 )
756 .await;
757 let mut prepared = prepared.into_iter();
758 let first = prepared.next().expect("first").expect("first edit");
759 let second = prepared.next().expect("second").expect("second edit");
760 assert!(prepared.next().is_none());
761 assert_ne!(
762 OperationFingerprint::from_prepared_operation(first.operation()),
763 OperationFingerprint::from_prepared_operation(second.operation())
764 );
765 first
766 .execute_for_test(execution_context(root.path()))
767 .await
768 .expect("first edit");
769 second
770 .execute_for_test(execution_context(root.path()))
771 .await
772 .expect("chained edit");
773 assert_eq!(
774 fs::read_to_string(root.path().join("value.txt")).unwrap(),
775 "ALPHA BETA gamma"
776 );
777 }
778
779 #[cfg(any(unix, windows))]
780 #[tokio::test]
781 async fn chained_edit_matches_predecessor_output() {
782 let root = tempfile::tempdir().expect("root");
783 fs::write(root.path().join("value.txt"), "a").expect("fixture");
784 let prepared = EditTool::new(root.path())
785 .prepare_parallel(
786 context(root.path()),
787 vec![
788 edit_call("value.txt", "a", "b"),
789 edit_call("value.txt", "b", "c"),
790 ],
791 )
792 .await;
793 let mut prepared = prepared.into_iter();
794 let first = prepared.next().expect("first").expect("first edit");
795 let second = prepared.next().expect("second").expect("chained edit");
797 first
798 .execute_for_test(execution_context(root.path()))
799 .await
800 .expect("first edit");
801 second
802 .execute_for_test(execution_context(root.path()))
803 .await
804 .expect("chained edit");
805 assert_eq!(
806 fs::read_to_string(root.path().join("value.txt")).unwrap(),
807 "c"
808 );
809 }
810
811 #[cfg(any(unix, windows))]
812 #[tokio::test]
813 async fn failed_chain_member_skips_without_breaking_the_chain() {
814 let root = tempfile::tempdir().expect("root");
815 fs::write(root.path().join("value.txt"), "a b c").expect("fixture");
816 let prepared = EditTool::new(root.path())
817 .prepare_parallel(
818 context(root.path()),
819 vec![
820 edit_call("value.txt", "a", "A"),
821 edit_call("value.txt", "missing", "X"),
822 edit_call("value.txt", "c", "C"),
823 ],
824 )
825 .await;
826 let mut prepared = prepared.into_iter();
827 let first = prepared.next().expect("first").expect("first edit");
828 let middle = prepared.next().expect("middle");
829 assert!(matches!(middle, Err(ToolError::Failed(_))));
830 let third = prepared.next().expect("third").expect("third edit");
832 first
833 .execute_for_test(execution_context(root.path()))
834 .await
835 .expect("first edit");
836 third
837 .execute_for_test(execution_context(root.path()))
838 .await
839 .expect("third edit");
840 assert_eq!(
841 fs::read_to_string(root.path().join("value.txt")).unwrap(),
842 "A b C"
843 );
844 }
845
846 #[cfg(any(unix, windows))]
847 #[tokio::test]
848 async fn early_preparation_errors_stay_isolated_in_a_batch() {
849 let root = tempfile::tempdir().expect("root");
850 fs::write(root.path().join("value.txt"), "alpha beta").expect("fixture");
851 let prepared = EditTool::new(root.path())
852 .prepare_parallel(
853 context(root.path()),
854 vec![
855 edit_call("missing.txt", "alpha", "ALPHA"),
856 edit_call("value.txt", "beta", "BETA"),
857 ],
858 )
859 .await;
860 let mut prepared = prepared.into_iter();
861 assert!(matches!(
862 prepared.next().expect("first"),
863 Err(ToolError::Failed(_))
864 ));
865 let second = prepared.next().expect("second").expect("second edit");
866 second
867 .execute_for_test(execution_context(root.path()))
868 .await
869 .expect("valid edit");
870 assert_eq!(
871 fs::read_to_string(root.path().join("value.txt")).unwrap(),
872 "alpha BETA"
873 );
874 }
875
876 #[cfg(any(unix, windows))]
877 #[tokio::test]
878 async fn chained_edit_requires_predecessor_and_detects_external_changes() {
879 let root = tempfile::tempdir().expect("root");
880 fs::write(root.path().join("value.txt"), "a b").expect("fixture");
881 let tool = EditTool::new(root.path());
882 let prepared = tool
883 .prepare_parallel(
884 context(root.path()),
885 vec![
886 edit_call("value.txt", "a", "A"),
887 edit_call("value.txt", "b", "B"),
888 ],
889 )
890 .await;
891 let mut prepared = prepared.into_iter();
892 let first = prepared.next().expect("first").expect("first edit");
893 let second = prepared.next().expect("second").expect("second edit");
894
895 let error = second
897 .revalidate_for_execution_for_test()
898 .await
899 .expect_err("predecessor has not run yet");
900 assert!(matches!(error, ToolError::OperationChanged(_)));
901
902 first
903 .execute_for_test(execution_context(root.path()))
904 .await
905 .expect("first edit");
906
907 fs::write(root.path().join("value.txt"), "tampered").expect("external write");
909 let error = second
910 .execute_for_test(execution_context(root.path()))
911 .await
912 .expect_err("external change must fail the chained edit");
913 assert!(matches!(error, ToolError::OperationChanged(_)));
914 }
915}