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