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)]
14#[cfg(all(test, unix))] // test fixtures build canonical targets from POSIX paths
15pub(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
91/// Execute a normalized, pre-authorized command through one planner/committer.
92pub(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
136/// Compatibility entry point used by the public Rust library facades. MCP
137/// dispatch performs receipt orchestration around the same prepared engine;
138/// these direct library calls intentionally preserve their historical return
139/// types while sharing all planner/committer behavior.
140pub(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
168/// Direct Rust callers of the historical single-file facades matched on the
169/// concrete error variants before the unified engine added path context for
170/// MCP recovery. Keep that public contract intact at the adapter boundary;
171/// MCP dispatch calls `handle_prepared` directly and therefore retains the
172/// structured path context it needs.
173fn 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}