1mod domain;
8mod wire;
9
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13#[cfg(test)]
14pub(crate) use domain::CanonicalEditTarget;
15pub use domain::{
16 EditChange, EditCommand, EditMode, EditOperation, EditSurface, EditVerification,
17 NormalizedEditCall, PreparedEditContext,
18};
19pub use wire::{normalize_edit_call, EditFileWire, EditFilesWire};
20
21use crate::errors::{Result, WinxError};
22use crate::server::SharedBashState;
23use crate::state::bash_state::{BashState, EditCheckpoint, EditMutationPostcondition};
24use crate::tools::file_write_or_edit::{
25 commit_edit, ensure_parent_dirs, hash_content, invalidate_edit_read_permit_at_target,
26 plan_explicit_text_edit_at_target, plan_revision_edit_at_target, write_no_follow_if_unchanged,
27};
28
29pub(crate) struct IndexedEditError {
30 pub index: usize,
31 pub path: PathBuf,
32 pub mode: EditMode,
33 pub source: WinxError,
34}
35
36impl IndexedEditError {
37 fn into_winx(self, surface: EditSurface) -> WinxError {
38 match surface {
39 EditSurface::MultiFileEdit => WinxError::MultiFilePlanError {
40 index: self.index + 1,
41 path: self.path,
42 source: Box::new(self.source),
43 },
44 EditSurface::EditFiles => WinxError::IndexedEditError {
45 index: self.index + 1,
46 path: self.path,
47 mode: self.mode.as_str().to_string(),
48 source: Box::new(self.source),
49 },
50 EditSurface::FileWriteOrEdit | EditSurface::ApplyPatch | EditSurface::UndoEdit => {
51 WinxError::EditContextError { path: self.path, source: Box::new(self.source) }
52 }
53 }
54 }
55}
56
57pub(crate) enum EditPlan {
58 Apply(Vec<crate::tools::file_write_or_edit::PlannedEdit>),
59 Undo(Box<UndoPlan>),
60}
61
62pub(crate) struct UndoPlan {
63 path: PathBuf,
64 file_path_str: String,
65 on_disk: String,
66 checkpoint: EditCheckpoint,
67 undo_id: Option<String>,
68 legacy_lifo: bool,
69}
70
71#[derive(Clone, Debug)]
72pub(crate) struct EditExecution {
73 pub text: String,
74 pub revisions: Vec<String>,
75 pub undo_ids: Vec<Option<String>>,
76 pub next_undo_id: Option<String>,
77 pub postconditions: Vec<EditMutationPostcondition>,
78 pub committed_paths: Vec<String>,
79 pub uncommitted_paths: Vec<String>,
80 pub partial_failure: Option<PartialEditFailure>,
81}
82
83#[derive(Clone, Debug)]
84pub(crate) struct PartialEditFailure {
85 pub failed_index: usize,
86 pub failed_path: String,
87 pub message: String,
88}
89
90pub(crate) async fn handle_prepared(
92 bash_state_arc: &SharedBashState,
93 prepared: PreparedEditContext,
94) -> Result<EditExecution> {
95 let mut bash_state_guard = bash_state_arc.lock().await;
96 {
97 let state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
98 if prepared.thread_id != state.current_thread_id {
99 return Err(WinxError::ThreadIdMismatch(prepared.thread_id));
100 }
101 prepared.authorize_current_state(state)?;
102 }
103
104 let mut state = bash_state_guard.take().ok_or(WinxError::BashStateNotInitialized)?;
105 let recovery_state = state.clone();
106 let joined = tokio::task::spawn_blocking(move || {
107 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
108 let plan = plan_command(&mut state, &prepared)?;
109 commit_plan(&mut state, &prepared, plan)
110 }))
111 .unwrap_or_else(|_| {
112 Err(WinxError::CommandExecutionError(format!(
113 "{} panicked on the blocking worker",
114 prepared.surface.tool_name()
115 )))
116 });
117 (state, result)
118 })
119 .await;
120
121 match joined {
122 Ok((state, result)) => {
123 *bash_state_guard = Some(state);
124 result
125 }
126 Err(error) => {
127 *bash_state_guard = Some(recovery_state);
128 Err(WinxError::CommandExecutionError(format!(
129 "unified EditFiles blocking task failed: {error}"
130 )))
131 }
132 }
133}
134
135pub(crate) async fn handle_legacy_tool<T: serde::Serialize>(
140 bash_state_arc: &SharedBashState,
141 surface: EditSurface,
142 request: T,
143) -> Result<EditExecution> {
144 let arguments = serde_json::to_value(request)
145 .map_err(|error| WinxError::SerializationError(error.to_string()))?;
146 let normalized = normalize_edit_call(surface, arguments)?;
147 let prepared = {
148 let state = bash_state_arc.lock().await;
149 let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
150 PreparedEditContext::prepare(
151 normalized,
152 state,
153 crate::tool_policy::ToolPolicy::default().edit_permissions(),
154 )?
155 };
156 let outcome = handle_prepared(bash_state_arc, prepared)
157 .await
158 .map_err(|error| preserve_legacy_error(surface, error))?;
159 if surface == EditSurface::MultiFileEdit {
160 if let Some(partial) = outcome.partial_failure.as_ref() {
161 return Err(WinxError::CommandExecutionError(partial.message.clone()));
162 }
163 }
164 Ok(outcome)
165}
166
167fn preserve_legacy_error(surface: EditSurface, error: WinxError) -> WinxError {
173 match (surface, error) {
174 (
175 EditSurface::FileWriteOrEdit | EditSurface::ApplyPatch | EditSurface::UndoEdit,
176 WinxError::EditContextError { source, .. },
177 ) => *source,
178 (_, error) => error,
179 }
180}
181
182fn plan_command(state: &mut BashState, prepared: &PreparedEditContext) -> Result<EditPlan> {
183 match &prepared.command {
184 EditCommand::Apply { changes } => plan_apply(state, prepared, changes),
185 EditCommand::Undo { undo_id, legacy_lifo, .. } => {
186 let path = prepared
187 .targets()
188 .first()
189 .ok_or_else(|| WinxError::InvalidInput("undo target is missing".to_string()))?;
190 path.validate_binding()?;
191 let path = path.path().to_path_buf();
192 let file_path_str = path.to_string_lossy().into_owned();
193 let (checkpoint, planned_undo_id, wrote_hash) = if let Some(undo_id) = undo_id {
194 let (latest, latest_id, wrote_hash) =
195 state.latest_receipt_bound_checkpoint(&file_path_str).ok_or_else(|| {
196 WinxError::UndoExpired { path: path.clone(), undo_id: undo_id.clone() }
197 })?;
198 if latest_id != *undo_id {
199 return Err(WinxError::UndoOutOfOrder {
200 path: path.clone(),
201 undo_id: undo_id.clone(),
202 latest_undo_id: latest_id,
203 });
204 }
205 (latest, Some(undo_id.clone()), wrote_hash)
206 } else {
207 let latest = state
208 .latest_edit_checkpoint_for(&file_path_str)
209 .ok_or_else(|| WinxError::UndoCheckpointNotFound { path: path.clone() })?;
210 let wrote_hash = state
211 .whitelist_for_overwrite
212 .get(&file_path_str)
213 .map(|entry| entry.file_hash.clone())
214 .ok_or_else(|| WinxError::UndoCheckpointNotFound { path: path.clone() })?;
215 (latest, None, wrote_hash)
216 };
217 if checkpoint.path != path || checkpoint.file_path_str != file_path_str {
218 return Err(WinxError::FileChangedAfterEdit {
219 path,
220 message:
221 "undo checkpoint target no longer matches the canonical preflight target"
222 .to_string(),
223 });
224 }
225 let on_disk = std::fs::read_to_string(&path).map_err(|error| {
226 WinxError::FileChangedAfterEdit {
227 path: path.clone(),
228 message: format!("the file is unavailable before undo: {error}"),
229 }
230 })?;
231 if hash_content(&on_disk) != wrote_hash {
232 return Err(WinxError::FileChangedAfterEdit {
233 path,
234 message: format!(
235 "{file_path_str} changed since the Winx edit bound to this undo checkpoint; re-read it and preserve the newer content"
236 ),
237 });
238 }
239 Ok(EditPlan::Undo(Box::new(UndoPlan {
240 path: checkpoint.path.clone(),
241 file_path_str,
242 on_disk,
243 checkpoint,
244 undo_id: planned_undo_id,
245 legacy_lifo: *legacy_lifo,
246 })))
247 }
248 }
249}
250
251fn plan_apply(
252 state: &mut BashState,
253 prepared: &PreparedEditContext,
254 changes: &[EditChange],
255) -> Result<EditPlan> {
256 let mut planned = Vec::with_capacity(changes.len());
257 for (index, change) in changes.iter().enumerate() {
258 let target = prepared.targets().get(index).ok_or_else(|| {
259 WinxError::InvalidInput(format!("canonical target missing for edit {}", index + 1))
260 })?;
261 target.validate_binding()?;
262 let result = match change {
263 EditChange::Replace { content, .. } => {
264 plan_explicit_text_edit_at_target(state, target.path(), false, content, false)
265 }
266 EditChange::SearchReplace { content, .. } => {
267 plan_explicit_text_edit_at_target(state, target.path(), true, content, false)
268 }
269 EditChange::LinePatch { expected_revision, patches, .. } => {
270 let required_ranges = crate::tools::apply_patch::required_read_ranges(patches);
271 plan_revision_edit_at_target(
272 state,
273 target.path(),
274 expected_revision,
275 &required_ranges,
276 |content| crate::tools::apply_patch::apply_line_patches(content, patches),
277 )
278 }
279 };
280 match result {
281 Ok(edit) => planned.push(edit),
282 Err(error) => {
283 if error.is_search_match_conflict() {
284 invalidate_edit_read_permit_at_target(state, target.path());
285 }
286 return Err(IndexedEditError {
287 index,
288 path: target.path().to_path_buf(),
289 mode: change.mode(),
290 source: error,
291 }
292 .into_winx(prepared.surface));
293 }
294 }
295 }
296 let mut seen = HashSet::with_capacity(planned.len());
297 for edit in &planned {
298 if !seen.insert(edit.target()) {
299 return Err(WinxError::InvalidInput(format!(
300 "EditFiles targets {:?} more than once",
301 edit.target()
302 )));
303 }
304 }
305 let temp_edits = planned
306 .iter()
307 .map(|edit| crate::utils::agent_temp::TempEdit {
308 path: edit.path(),
309 previous_bytes: edit.previous_bytes(),
310 new_bytes: edit.new_bytes(),
311 is_new: edit.previous_bytes() == 0 && !edit.path().exists(),
312 })
313 .collect::<Vec<_>>();
314 crate::utils::agent_temp::validate_batch_quota(
315 &state.workspace_root,
316 &state.current_thread_id,
317 &temp_edits,
318 )?;
319 Ok(EditPlan::Apply(planned))
320}
321
322fn commit_plan(
323 state: &mut BashState,
324 prepared: &PreparedEditContext,
325 plan: EditPlan,
326) -> Result<EditExecution> {
327 match plan {
328 EditPlan::Apply(planned) => {
329 commit_apply_with(state, prepared, planned, |state, edit, _| commit_edit(state, edit))
330 }
331 EditPlan::Undo(plan) => {
332 prepared
333 .targets()
334 .first()
335 .ok_or_else(|| WinxError::InvalidInput("undo target is missing".to_string()))?
336 .validate_binding()?;
337 commit_undo(state, *plan)
338 }
339 }
340}
341
342fn commit_apply_with<F>(
343 state: &mut BashState,
344 prepared: &PreparedEditContext,
345 planned: Vec<crate::tools::file_write_or_edit::PlannedEdit>,
346 mut commit: F,
347) -> Result<EditExecution>
348where
349 F: FnMut(
350 &mut BashState,
351 crate::tools::file_write_or_edit::PlannedEdit,
352 usize,
353 ) -> Result<String>,
354{
355 let targets = planned.iter().map(|edit| edit.target().to_string()).collect::<Vec<_>>();
356 let total = targets.len();
357 let mut summaries = Vec::with_capacity(total);
358 let mut revisions = Vec::with_capacity(total);
359 let mut undo_ids = Vec::with_capacity(total);
360 let mut postconditions = Vec::with_capacity(total);
361 for (committed, edit) in planned.into_iter().enumerate() {
362 let binding = prepared.targets().get(committed).ok_or_else(|| {
363 WinxError::InvalidInput(format!(
364 "canonical target missing before commit {}",
365 committed + 1
366 ))
367 })?;
368 binding.validate_binding()?;
369 let target = edit.target().to_string();
370 let revision = edit.new_revision();
371 let new_hash = edit.new_hash();
372 let prior_undo = state.next_undo_id_for(&target);
373 match commit(state, edit, committed) {
374 Ok(summary) => {
375 let current_undo = state.next_undo_id_for(&target);
376 undo_ids.push((current_undo != prior_undo).then_some(current_undo).flatten());
377 revisions.push(revision);
378 postconditions
379 .push(EditMutationPostcondition { path: target.clone(), sha256: new_hash });
380 summaries.push(format!("[{target}]\n{summary}"));
381 }
382 Err(error) => {
383 if committed == 0 {
384 return Err(error);
385 }
386 let message = format!(
387 "{} committed {committed} of {total} files, then failed writing {target}: {error}. The committed prefix was not rolled back; re-read and retry only the uncommitted suffix.",
388 prepared.surface.tool_name()
389 );
390 return Ok(EditExecution {
391 text: message.clone(),
392 revisions,
393 undo_ids,
394 next_undo_id: None,
395 postconditions,
396 committed_paths: targets[..committed].to_vec(),
397 uncommitted_paths: targets[committed..].to_vec(),
398 partial_failure: Some(PartialEditFailure {
399 failed_index: committed + 1,
400 failed_path: target,
401 message,
402 }),
403 });
404 }
405 }
406 }
407 let text = match prepared.surface {
408 EditSurface::MultiFileEdit => {
409 format!("MultiFileEdit applied all {total} edits:\n\n{}", summaries.join("\n\n"))
410 }
411 EditSurface::EditFiles if total > 1 => {
412 format!("EditFiles applied all {total} edits:\n\n{}", summaries.join("\n\n"))
413 }
414 _ => summaries
415 .into_iter()
416 .next()
417 .and_then(|summary| summary.split_once('\n').map(|(_, text)| text.to_string()))
418 .unwrap_or_default(),
419 };
420 Ok(EditExecution {
421 text,
422 revisions,
423 undo_ids,
424 next_undo_id: None,
425 postconditions,
426 committed_paths: targets,
427 uncommitted_paths: Vec::new(),
428 partial_failure: None,
429 })
430}
431
432fn commit_undo(state: &mut BashState, plan: UndoPlan) -> Result<EditExecution> {
433 ensure_parent_dirs(&plan.path)?;
434 write_no_follow_if_unchanged(
435 &plan.path,
436 plan.checkpoint.prior_content.as_bytes(),
437 Some(&plan.on_disk),
438 )?;
439 let removed = if plan.legacy_lifo {
440 state.pop_edit_checkpoint_for(&plan.file_path_str)
441 } else {
442 state.pop_latest_edit_checkpoint_by_id(
443 &plan.file_path_str,
444 plan.undo_id.as_deref().unwrap_or_default(),
445 )
446 };
447 if removed.is_none() {
448 return Err(WinxError::UndoExpired {
449 path: plan.path,
450 undo_id: plan.undo_id.unwrap_or_default(),
451 });
452 }
453 match plan.checkpoint.prior_whitelist {
454 Some(whitelist) => state.set_whitelist_entry(&plan.file_path_str, whitelist),
455 None => {
456 state.remove_whitelist_entry(&plan.file_path_str);
457 }
458 }
459 let remaining = state.undo_checkpoint_count_for(&plan.file_path_str);
460 let next_undo_id = state.next_undo_id_for(&plan.file_path_str);
461 let lines = plan.checkpoint.prior_content.lines().count();
462 Ok(EditExecution {
463 text: format!(
464 "Reverted {} to its content before the last edit ({lines} lines). {remaining} earlier checkpoint(s) remain for this file.",
465 plan.file_path_str
466 ),
467 revisions: vec![crate::tools::read_files::revision_from_hash(&hash_content(
468 &plan.checkpoint.prior_content,
469 ))],
470 undo_ids: Vec::new(),
471 next_undo_id,
472 postconditions: vec![EditMutationPostcondition {
473 path: plan.file_path_str.clone(),
474 sha256: hash_content(&plan.checkpoint.prior_content),
475 }],
476 committed_paths: vec![plan.file_path_str],
477 uncommitted_paths: Vec::new(),
478 partial_failure: None,
479 })
480}
481
482#[cfg(test)]
483mod tests {
484 #![allow(clippy::expect_used)]
485
486 use std::sync::Arc;
487
488 use super::*;
489 use crate::tool_policy::ToolPolicy;
490 use crate::types::{AllowedGlobs, Modes};
491 use tokio::sync::Mutex;
492
493 fn state_for(root: &std::path::Path) -> BashState {
494 let mut state = BashState::new();
495 state.initialized = true;
496 state.current_thread_id = "thread".to_string();
497 state.cwd = root.to_path_buf();
498 state.workspace_root = root.to_path_buf();
499 state.mode = Modes::Wcgw;
500 state
501 }
502
503 fn canonical_identity(path: &std::path::Path) -> String {
504 path.canonicalize().expect("test edit target must exist").to_string_lossy().into_owned()
505 }
506
507 fn record_full_read(state: &mut BashState, path: &std::path::Path, content: &str) {
508 let total_lines = content.lines().count().max(1);
509 state.record_read_coverage(
510 &canonical_identity(path),
511 [(1, total_lines)],
512 hash_content(content),
513 total_lines,
514 );
515 }
516
517 fn prepare_dark(state: &BashState, files: &serde_json::Value) -> Result<PreparedEditContext> {
518 let normalized = normalize_edit_call(
519 EditSurface::EditFiles,
520 serde_json::json!({
521 "operation": "apply",
522 "files": files,
523 "thread_id": "thread",
524 "workspace_root": state.workspace_root
525 }),
526 )?;
527 PreparedEditContext::prepare(normalized, state, ToolPolicy::default().edit_permissions())
528 }
529
530 fn prepare_unified_undo(
531 state: &BashState,
532 file_path: &std::path::Path,
533 undo_id: &str,
534 ) -> Result<PreparedEditContext> {
535 let normalized = normalize_edit_call(
536 EditSurface::EditFiles,
537 serde_json::json!({
538 "operation": "undo",
539 "files": [{
540 "file_path": file_path,
541 "mode": "undo",
542 "undo_id": undo_id
543 }],
544 "thread_id": "thread",
545 "workspace_root": state.workspace_root
546 }),
547 )?;
548 PreparedEditContext::prepare(normalized, state, ToolPolicy::default().edit_permissions())
549 }
550
551 #[test]
552 fn explicit_modes_have_canonical_identity_independent_of_legacy_wire() -> Result<()> {
553 let root = tempfile::tempdir()?;
554 let path = root.path().join("file.txt");
555 let mut state = BashState::new();
556 state.initialized = true;
557 state.current_thread_id = "thread".to_string();
558 state.cwd = root.path().to_path_buf();
559 state.workspace_root = root.path().to_path_buf();
560 state.mode = Modes::Wcgw;
561 let legacy = normalize_edit_call(
562 EditSurface::FileWriteOrEdit,
563 serde_json::json!({
564 "file_path": &path,
565 "percentage_to_change": 100,
566 "text_or_search_replace_blocks": "new\n",
567 "thread_id": "thread",
568 "workspace_root": root.path()
569 }),
570 )?;
571 let modern = normalize_edit_call(
572 EditSurface::EditFiles,
573 serde_json::json!({
574 "operation": "apply",
575 "files": [{"file_path": path, "mode": "replace", "content": "new\n"}],
576 "thread_id": "thread",
577 "workspace_root": root.path()
578 }),
579 )?;
580 let permissions = ToolPolicy::default().edit_permissions();
581 let legacy = PreparedEditContext::prepare(legacy, &state, permissions)?;
582 let modern = PreparedEditContext::prepare(modern, &state, permissions)?;
583 assert_eq!(legacy.canonical_value(), modern.canonical_value());
584 Ok(())
585 }
586
587 #[test]
588 fn canonical_identity_ignores_equivalent_raw_workspace_spelling() -> Result<()> {
589 let root = tempfile::tempdir()?;
590 let path = root.path().join("file.txt");
591 let state = state_for(root.path());
592 let legacy = normalize_edit_call(
593 EditSurface::FileWriteOrEdit,
594 serde_json::json!({
595 "file_path": path,
596 "percentage_to_change": 100,
597 "text_or_search_replace_blocks": "new\n",
598 "thread_id": "thread",
599 "workspace_root": root.path().join(".")
600 }),
601 )?;
602 let modern = normalize_edit_call(
603 EditSurface::EditFiles,
604 serde_json::json!({
605 "operation": "apply",
606 "files": [{"file_path": path, "mode": "replace", "content": "new\n"}],
607 "thread_id": "thread",
608 "workspace_root": root.path()
609 }),
610 )?;
611 let permissions = ToolPolicy::default().edit_permissions();
612 assert_eq!(
613 PreparedEditContext::prepare(legacy, &state, permissions)?.canonical_value(),
614 PreparedEditContext::prepare(modern, &state, permissions)?.canonical_value()
615 );
616 Ok(())
617 }
618
619 #[test]
620 fn error_context_preserves_exact_legacy_surface_and_typed_unified_index() {
621 let source = || WinxError::SearchBlockNotFound("missing block".to_string());
622 let indexed = |source| IndexedEditError {
623 index: 0,
624 path: "/workspace/file.rs".into(),
625 mode: EditMode::SearchReplace,
626 source,
627 };
628
629 let single = indexed(source()).into_winx(EditSurface::FileWriteOrEdit);
630 assert_eq!(single.to_string(), source().to_string());
631 assert!(!single.to_string().contains("MultiFileEdit aborted"));
632 assert!(matches!(single, WinxError::EditContextError { .. }));
633
634 let patch = indexed(source()).into_winx(EditSurface::ApplyPatch);
635 assert_eq!(patch.to_string(), source().to_string());
636 assert!(!patch.to_string().contains("MultiFileEdit aborted"));
637
638 let unified = indexed(source()).into_winx(EditSurface::EditFiles);
639 assert!(matches!(unified, WinxError::IndexedEditError { .. }));
640 assert!(unified.to_string().starts_with("EditFiles aborted before writing anything"));
641 assert!(!unified.to_string().contains("MultiFileEdit aborted"));
642
643 let legacy_batch = indexed(source()).into_winx(EditSurface::MultiFileEdit);
644 assert_eq!(
645 legacy_batch.to_string(),
646 "MultiFileEdit aborted before writing anything - file 1 (/workspace/file.rs) failed validation: Search block not found in content: missing block"
647 );
648 }
649
650 #[test]
651 fn prepared_authorization_fails_closed_after_mode_tightening() -> Result<()> {
652 let root = tempfile::tempdir()?;
653 let path = root.path().join("file.txt");
654 let mut state = state_for(root.path());
655 let prepared = prepare_dark(
656 &state,
657 &serde_json::json!([{
658 "file_path": path,
659 "mode": "replace",
660 "content": "new\n"
661 }]),
662 )?;
663
664 state.mode = Modes::Architect;
665 state.write_if_empty_mode.allowed_globs = AllowedGlobs::List(Vec::new());
666 state.file_edit_mode.allowed_globs = AllowedGlobs::List(Vec::new());
667 assert!(matches!(
668 prepared.authorize_current_state(&state),
669 Err(WinxError::FileOperationDenied { .. })
670 ));
671 Ok(())
672 }
673
674 #[test]
675 fn unified_surface_respects_each_custom_legacy_permission_shape_without_widening() -> Result<()>
676 {
677 let root = tempfile::tempdir()?;
678 let state = state_for(root.path());
679 let first = root.path().join("first.rs");
680 let second = root.path().join("second.rs");
681 let normalize = |files| {
682 normalize_edit_call(
683 EditSurface::EditFiles,
684 serde_json::json!({
685 "operation": "apply",
686 "files": files,
687 "thread_id": "thread",
688 "workspace_root": root.path()
689 }),
690 )
691 };
692
693 let single_replace = normalize(serde_json::json!([{
694 "file_path": first,
695 "mode": "replace",
696 "content": "one"
697 }]))?;
698 let batch_replace = normalize(serde_json::json!([
699 {"file_path": first, "mode": "replace", "content": "one"},
700 {"file_path": second, "mode": "replace", "content": "two"}
701 ]))?;
702 let line_patch = normalize(serde_json::json!([{
703 "file_path": first,
704 "mode": "line_patch",
705 "expected_revision": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
706 "patches": [{"start_line": 1, "delete_lines": 0, "replacement": "one"}]
707 }]))?;
708
709 let file_write = ToolPolicy::from_allowed_tools(["FileWriteOrEdit"])?;
710 assert!(PreparedEditContext::prepare(
711 single_replace.clone(),
712 &state,
713 file_write.edit_permissions()
714 )
715 .is_ok());
716 assert!(PreparedEditContext::prepare(
717 batch_replace.clone(),
718 &state,
719 file_write.edit_permissions()
720 )
721 .is_err());
722 assert!(PreparedEditContext::prepare(
723 line_patch.clone(),
724 &state,
725 file_write.edit_permissions()
726 )
727 .is_err());
728
729 let apply_patch = ToolPolicy::from_allowed_tools(["ApplyPatch"])?;
730 assert!(PreparedEditContext::prepare(line_patch, &state, apply_patch.edit_permissions())
731 .is_ok());
732 assert!(PreparedEditContext::prepare(
733 single_replace.clone(),
734 &state,
735 apply_patch.edit_permissions()
736 )
737 .is_err());
738
739 let multi = ToolPolicy::from_allowed_tools(["MultiFileEdit"])?;
740 assert!(
741 PreparedEditContext::prepare(batch_replace, &state, multi.edit_permissions()).is_ok()
742 );
743 assert!(
744 PreparedEditContext::prepare(single_replace, &state, multi.edit_permissions()).is_err()
745 );
746 Ok(())
747 }
748
749 #[tokio::test]
750 async fn receipt_bound_undo_is_strict_lifo_even_when_old_written_content_cycles() -> Result<()>
751 {
752 let root = tempfile::tempdir()?;
753 let path = root.path().join("file.txt");
754 std::fs::write(&path, "one\n")?;
755 let mut state = state_for(root.path());
756 record_full_read(&mut state, &path, "one\n");
757 let path_identity = canonical_identity(&path);
758 let slot = Arc::new(Mutex::new(Some(state)));
759
760 let mut undo_ids = Vec::new();
761 for content in ["two\n", "one\n", "two\n"] {
762 let prepared = {
763 let guard = slot.lock().await;
764 prepare_dark(
765 guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?,
766 &serde_json::json!([{
767 "file_path": path,
768 "mode": "replace",
769 "content": content
770 }]),
771 )?
772 };
773 let outcome = handle_prepared(&slot, prepared).await?;
774 undo_ids.push(
775 outcome
776 .undo_ids
777 .first()
778 .and_then(Clone::clone)
779 .ok_or_else(|| WinxError::ParseError("missing undo id".to_string()))?,
780 );
781 }
782 assert_eq!(std::fs::read_to_string(&path)?, "two\n");
783
784 let stale_undo = {
785 let guard = slot.lock().await;
786 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
787 prepare_unified_undo(state, &path, &undo_ids[0])?
788 };
789 let Err(error) = handle_prepared(&slot, stale_undo).await else {
790 return Err(WinxError::ParseError("stale undo unexpectedly succeeded".to_string()));
791 };
792 assert!(matches!(error, WinxError::UndoOutOfOrder { .. }));
793 assert_eq!(std::fs::read_to_string(&path)?, "two\n");
794
795 let latest = {
796 let guard = slot.lock().await;
797 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
798 prepare_unified_undo(state, &path, &undo_ids[2])?
799 };
800 let outcome = handle_prepared(&slot, latest.clone()).await?;
801 assert_eq!(outcome.next_undo_id.as_deref(), Some(undo_ids[1].as_str()));
802 assert_eq!(std::fs::read_to_string(&path)?, "one\n");
803
804 let count = slot
805 .lock()
806 .await
807 .as_ref()
808 .ok_or(WinxError::BashStateNotInitialized)?
809 .undo_checkpoint_count_for(&path_identity);
810 let Err(replay_error) = handle_prepared(&slot, latest).await else {
811 return Err(WinxError::ParseError(
812 "duplicate undo unexpectedly popped twice".to_string(),
813 ));
814 };
815 assert!(matches!(replay_error, WinxError::UndoOutOfOrder { .. }));
816 assert_eq!(
817 slot.lock()
818 .await
819 .as_ref()
820 .ok_or(WinxError::BashStateNotInitialized)?
821 .undo_checkpoint_count_for(&path_identity),
822 count
823 );
824 Ok(())
825 }
826
827 #[tokio::test]
828 async fn relative_target_is_bound_to_preflight_cwd() -> Result<()> {
829 let root = tempfile::tempdir()?;
830 let first_dir = root.path().join("first");
831 let second_dir = root.path().join("second");
832 std::fs::create_dir_all(&first_dir)?;
833 std::fs::create_dir_all(&second_dir)?;
834 let first = first_dir.join("same.txt");
835 let second = second_dir.join("same.txt");
836 std::fs::write(&first, "first old\n")?;
837 std::fs::write(&second, "second old\n")?;
838 let mut state = state_for(root.path());
839 state.cwd.clone_from(&first_dir);
840 record_full_read(&mut state, &first, "first old\n");
841 let prepared = prepare_dark(
842 &state,
843 &serde_json::json!([{
844 "file_path": "same.txt",
845 "mode": "replace",
846 "content": "first new\n"
847 }]),
848 )?;
849 state.cwd = second_dir;
850 let slot = Arc::new(Mutex::new(Some(state)));
851
852 handle_prepared(&slot, prepared).await?;
853 assert_eq!(std::fs::read_to_string(first)?, "first new\n");
854 assert_eq!(std::fs::read_to_string(second)?, "second old\n");
855 Ok(())
856 }
857
858 #[cfg(unix)]
859 #[tokio::test]
860 async fn symlink_retarget_after_preflight_cannot_redirect_edit_or_undo() -> Result<()> {
861 use std::os::unix::fs::symlink;
862
863 let root = tempfile::tempdir()?;
864 let first_dir = root.path().join("first");
865 let second_dir = root.path().join("second");
866 std::fs::create_dir_all(&first_dir)?;
867 std::fs::create_dir_all(&second_dir)?;
868 let first = first_dir.join("file.txt");
869 let second = second_dir.join("file.txt");
870 std::fs::write(&first, "first old\n")?;
871 std::fs::write(&second, "second old\n")?;
872 let link = root.path().join("current");
873 symlink(&first_dir, &link)?;
874
875 let mut state = state_for(root.path());
876 record_full_read(&mut state, &first, "first old\n");
877 let prepared = prepare_dark(
878 &state,
879 &serde_json::json!([{
880 "file_path": "current/file.txt",
881 "mode": "replace",
882 "content": "first new\n"
883 }]),
884 )?;
885 std::fs::remove_file(&link)?;
886 symlink(&second_dir, &link)?;
887 let slot = Arc::new(Mutex::new(Some(state)));
888 let outcome = handle_prepared(&slot, prepared).await?;
889 assert_eq!(std::fs::read_to_string(&first)?, "first new\n");
890 assert_eq!(std::fs::read_to_string(&second)?, "second old\n");
891
892 std::fs::remove_file(&link)?;
893 symlink(&first_dir, &link)?;
894 let undo_id = outcome
895 .undo_ids
896 .first()
897 .and_then(Clone::clone)
898 .ok_or_else(|| WinxError::ParseError("missing undo id".to_string()))?;
899 let undo = {
900 let guard = slot.lock().await;
901 prepare_unified_undo(
902 guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?,
903 &link.join("file.txt"),
904 &undo_id,
905 )?
906 };
907 std::fs::remove_file(&link)?;
908 symlink(&second_dir, &link)?;
909 handle_prepared(&slot, undo).await?;
910 assert_eq!(std::fs::read_to_string(first)?, "first old\n");
911 assert_eq!(std::fs::read_to_string(second)?, "second old\n");
912 Ok(())
913 }
914
915 #[cfg(unix)]
916 #[tokio::test]
917 async fn canonical_parent_retarget_cannot_redirect_commit_even_with_identical_bytes(
918 ) -> Result<()> {
919 use std::os::unix::fs::symlink;
920
921 let workspace = tempfile::tempdir()?;
922 let outside = tempfile::tempdir()?;
923 let parent = workspace.path().join("bound");
924 let saved_parent = workspace.path().join("bound-original");
925 std::fs::create_dir(&parent)?;
926 let intended = parent.join("file.txt");
927 let redirected = outside.path().join("file.txt");
928 std::fs::write(&intended, "same bytes\n")?;
929 std::fs::write(&redirected, "same bytes\n")?;
930
931 let mut state = state_for(workspace.path());
932 record_full_read(&mut state, &intended, "same bytes\n");
933 let prepared = prepare_dark(
934 &state,
935 &serde_json::json!([{
936 "file_path": intended,
937 "mode": "replace",
938 "content": "agent replacement\n"
939 }]),
940 )?;
941
942 std::fs::rename(&parent, &saved_parent)?;
943 symlink(outside.path(), &parent)?;
944 let slot = Arc::new(Mutex::new(Some(state)));
945 let error = handle_prepared(&slot, prepared)
946 .await
947 .expect_err("retargeted canonical parent must fail closed");
948
949 assert!(matches!(error, WinxError::FileChangedAfterEdit { .. }));
950 assert_eq!(std::fs::read_to_string(saved_parent.join("file.txt"))?, "same bytes\n");
951 assert_eq!(std::fs::read_to_string(redirected)?, "same bytes\n");
952 Ok(())
953 }
954
955 #[test]
956 fn injected_write_stage_failure_reports_committed_prefix() -> Result<()> {
957 let root = tempfile::tempdir()?;
958 let first = root.path().join("first.txt");
959 let second = root.path().join("second.txt");
960 std::fs::write(&first, "first old\n")?;
961 std::fs::write(&second, "second old\n")?;
962 let mut state = state_for(root.path());
963 record_full_read(&mut state, &first, "first old\n");
964 record_full_read(&mut state, &second, "second old\n");
965 let prepared = prepare_dark(
966 &state,
967 &serde_json::json!([
968 {"file_path": first, "mode": "replace", "content": "first new\n"},
969 {"file_path": second, "mode": "replace", "content": "second new\n"}
970 ]),
971 )?;
972 let EditPlan::Apply(planned) = plan_command(&mut state, &prepared)? else {
973 return Err(WinxError::ParseError("expected apply plan".to_string()));
974 };
975 let outcome = commit_apply_with(&mut state, &prepared, planned, |state, edit, index| {
976 if index == 1 {
977 return Err(WinxError::FileWriteError {
978 path: edit.path().to_path_buf(),
979 message: "injected write-stage failure".to_string(),
980 });
981 }
982 commit_edit(state, edit)
983 })?;
984
985 assert!(outcome.partial_failure.is_some());
986 assert_eq!(outcome.committed_paths, vec![canonical_identity(&first)]);
987 assert_eq!(outcome.uncommitted_paths, vec![canonical_identity(&second)]);
988 assert_eq!(std::fs::read_to_string(first)?, "first new\n");
989 assert_eq!(std::fs::read_to_string(second)?, "second old\n");
990 Ok(())
991 }
992
993 #[test]
994 fn postcondition_is_the_planned_commit_not_a_later_external_write() -> Result<()> {
995 let root = tempfile::tempdir()?;
996 let path = root.path().join("file.txt");
997 std::fs::write(&path, "old\n")?;
998 let mut state = state_for(root.path());
999 record_full_read(&mut state, &path, "old\n");
1000 let prepared = prepare_dark(
1001 &state,
1002 &serde_json::json!([{
1003 "file_path": &path,
1004 "mode": "replace",
1005 "content": "committed\n"
1006 }]),
1007 )?;
1008 let EditPlan::Apply(planned) = plan_command(&mut state, &prepared)? else {
1009 return Err(WinxError::ParseError("expected apply plan".to_string()));
1010 };
1011 let external_path = path.clone();
1012 let outcome = commit_apply_with(&mut state, &prepared, planned, |state, edit, _| {
1013 let summary = commit_edit(state, edit)?;
1014 std::fs::write(&external_path, "external\n")?;
1015 Ok(summary)
1016 })?;
1017
1018 assert_eq!(outcome.postconditions.len(), 1);
1019 assert_eq!(outcome.postconditions[0].sha256, hash_content("committed\n"));
1020 assert_ne!(outcome.postconditions[0].sha256, hash_content("external\n"));
1021 assert_eq!(std::fs::read_to_string(path)?, "external\n");
1022 Ok(())
1023 }
1024}