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