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