Skip to main content

winx_code_agent/tools/edit_files/
mod.rs

1//! Unified typed file-mutation engine.
2//!
3//! `EditFiles` is the compact public MCP surface. Hidden legacy aliases lower
4//! into the same module so cached clients keep working without duplicating
5//! planning, authorization, commit, recovery, or receipt semantics.
6
7mod 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
90/// Execute a normalized, pre-authorized command through one planner/committer.
91pub(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
135/// Compatibility entry point used by the public Rust library facades. MCP
136/// dispatch performs receipt orchestration around the same prepared engine;
137/// these direct library calls intentionally preserve their historical return
138/// types while sharing all planner/committer behavior.
139pub(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
167/// Direct Rust callers of the historical single-file facades matched on the
168/// concrete error variants before the unified engine added path context for
169/// MCP recovery. Keep that public contract intact at the adapter boundary;
170/// MCP dispatch calls `handle_prepared` directly and therefore retains the
171/// structured path context it needs.
172fn 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}