Skip to main content

winx_code_agent/tools/
multi_file_edit.rs

1//! Implementation of the `MultiFileEdit` tool.
2//!
3//! Applies edits across several files all-or-nothing at the COMPUTE stage: every
4//! file is validated and its new content computed in memory first (reusing
5//! `file_write_or_edit::plan_edit`), and only if ALL succeed is anything written.
6//! So a SEARCH block that fails to match in the last file leaves the earlier
7//! files untouched, instead of the half-edited tree N separate `FileWriteOrEdit`
8//! calls would leave.
9//!
10//! The write stage is a sequence of individually-atomic single-file renames
11//! (`commit_edit` -> preconditioned atomic replacement). It stops at the first I/O failure and
12//! reports which files were already written; it does NOT roll them back (each is
13//! already crash-safe on its own, and a second write pass could fail and corrupt
14//! more state).
15
16use std::collections::HashSet;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use tokio::sync::Mutex;
21use tracing::instrument;
22
23use crate::errors::{Result, WinxError};
24use crate::state::bash_state::BashState;
25use crate::tools::file_write_or_edit::{commit_edit, invalidate_edit_read_permit, plan_edit};
26use crate::types::{normalize_thread_id, FileEditEntry, MultiFileEdit};
27
28/// Upper bound on files per batch. The whole batch holds the `bash_state` lock
29/// across its (synchronous) file IO, so a huge batch would block the executor
30/// and other sessions for a long time. A real multi-file refactor is well under
31/// this; the cap is a guard against a pathological request.
32const MAX_FILES_PER_BATCH: usize = 100;
33
34#[instrument(level = "info", skip(bash_state_arc, multi))]
35pub async fn handle_tool_call(
36    bash_state_arc: &Arc<Mutex<Option<BashState>>>,
37    multi: MultiFileEdit,
38) -> Result<String> {
39    let mut bash_state_guard = bash_state_arc.lock().await;
40
41    // Cheap validation up front (needs only the current thread id).
42    {
43        let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
44        let thread_id = normalize_thread_id(&multi.thread_id);
45        if thread_id != bash_state.current_thread_id {
46            return Err(WinxError::ThreadIdMismatch(thread_id));
47        }
48    }
49    if multi.files.len() < 2 {
50        return Err(WinxError::ArgumentParseError(
51            "MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
52                .to_string(),
53        ));
54    }
55    if multi.files.len() > MAX_FILES_PER_BATCH {
56        return Err(WinxError::ArgumentParseError(format!(
57            "MultiFileEdit is limited to {MAX_FILES_PER_BATCH} files per batch (got {}); split the \
58             change into smaller batches.",
59            multi.files.len()
60        )));
61    }
62
63    // Move the state onto the blocking pool for the synchronous plan+commit IO
64    // (reading every file, then writing every file). The guard is held throughout,
65    // so the slot stays locked — mutual exclusion is preserved; `take` just lets us
66    // own the value across spawn_blocking. This frees the tokio worker instead of
67    // pinning it on up to MAX_FILES_PER_BATCH file reads/writes.
68    let mut state = bash_state_guard.take().ok_or(WinxError::BashStateNotInitialized)?;
69    let recovery_state = state.clone();
70    let files = multi.files;
71    let joined = tokio::task::spawn_blocking(move || {
72        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
73            apply_batch(&mut state, &files)
74        }))
75        .unwrap_or_else(|_| {
76            Err(WinxError::CommandExecutionError(
77                "MultiFileEdit panicked on the blocking worker".to_string(),
78            ))
79        });
80        (state, result)
81    })
82    .await;
83
84    match joined {
85        Ok((state, result)) => {
86            *bash_state_guard = Some(state);
87            result
88        }
89        Err(error) => {
90            *bash_state_guard = Some(recovery_state);
91            Err(WinxError::CommandExecutionError(format!(
92                "MultiFileEdit blocking task failed: {error}"
93            )))
94        }
95    }
96}
97
98/// Plan every file (all-or-nothing at the compute stage), reject duplicate
99/// targets, then commit sequentially. Synchronous (file IO) — runs on the
100/// blocking pool, never on a tokio worker.
101fn apply_batch(bash_state: &mut BashState, files: &[FileEditEntry]) -> Result<String> {
102    // PHASE 1: plan every file (validate + compute new content) with NO writes.
103    // Any failure aborts the whole batch having touched nothing on disk.
104    let mut planned = Vec::with_capacity(files.len());
105    for (index, entry) in files.iter().enumerate() {
106        let edit = match plan_edit(
107            bash_state,
108            &entry.file_path,
109            entry.percentage_to_change,
110            &entry.text_or_search_replace_blocks,
111            false,
112        ) {
113            Ok(edit) => edit,
114            Err(error) => {
115                if error.is_search_match_conflict() {
116                    invalidate_edit_read_permit(bash_state, &entry.file_path);
117                }
118                return Err(contextualize_plan_error(bash_state, index, entry, error));
119            }
120        };
121        planned.push(edit);
122    }
123
124    // Reject duplicate targets, checked on the RESOLVED path (so `a.txt` and its
125    // absolute form can't both slip through and clobber each other): two entries
126    // for the same file don't compose - the second is computed from the original,
127    // not the first's result, so it would silently overwrite the first.
128    let mut seen = HashSet::with_capacity(planned.len());
129    for edit in &planned {
130        if !seen.insert(edit.target()) {
131            return Err(WinxError::ArgumentParseError(format!(
132                "MultiFileEdit targets '{}' more than once; edits to the same file don't compose - \
133                 combine them into a single entry.",
134                edit.target()
135            )));
136        }
137    }
138
139    let temp_edits = planned
140        .iter()
141        .map(|edit| crate::utils::agent_temp::TempEdit {
142            path: edit.path(),
143            previous_bytes: edit.previous_bytes(),
144            new_bytes: edit.new_bytes(),
145            is_new: edit.previous_bytes() == 0 && !edit.path().exists(),
146        })
147        .collect::<Vec<_>>();
148    crate::utils::agent_temp::validate_batch_quota(
149        &bash_state.workspace_root,
150        &bash_state.current_thread_id,
151        &temp_edits,
152    )?;
153
154    // PHASE 2: commit sequentially. Each write is individually atomic (temp +
155    // rename). On the first failure, stop and report honestly without rolling
156    // back already-written files.
157    let total = planned.len();
158    let mut summaries = Vec::with_capacity(total);
159    for (committed, edit) in planned.into_iter().enumerate() {
160        let target = edit.target().to_string();
161        match commit_edit(bash_state, edit) {
162            Ok(summary) => summaries.push(format!("[{target}]\n{summary}")),
163            Err(e) => {
164                return Err(WinxError::CommandExecutionError(format!(
165                    "MultiFileEdit: committed {committed} of {total} files, then failed writing \
166                     {target}: {e}\nThe {committed} already-written file(s) were NOT rolled back. \
167                     Re-read the affected files and retry the rest."
168                )));
169            }
170        }
171    }
172
173    Ok(format!("MultiFileEdit applied all {total} edits:\n\n{}", summaries.join("\n\n")))
174}
175
176/// Preserve the original failure class so the MCP orchestration layer can emit
177/// `needs_read`, stale-file recovery, or SEARCH conflict guidance. SEARCH errors
178/// do not carry a path themselves, so wrap them in path-aware
179/// `MultiFilePlanError` context without flattening the source into a string.
180fn contextualize_plan_error(
181    bash_state: &BashState,
182    index: usize,
183    entry: &FileEditEntry,
184    error: WinxError,
185) -> WinxError {
186    let context = format!(
187        "MultiFileEdit aborted before writing anything - file {} ({}) failed validation",
188        index + 1,
189        entry.file_path
190    );
191    match error {
192        WinxError::FileAccessError { path, message } => {
193            WinxError::FileAccessError { path, message: format!("{context}: {message}") }
194        }
195        WinxError::TemporaryArtifactPolicy { path, temporary_artifact_dir, message } => {
196            WinxError::TemporaryArtifactPolicy {
197                path,
198                temporary_artifact_dir,
199                message: format!("{context}: {message}"),
200            }
201        }
202        source @ (WinxError::SearchBlockNotFound(_) | WinxError::SearchBlockAmbiguous { .. }) => {
203            WinxError::MultiFilePlanError {
204                index: index + 1,
205                path: resolved_entry_path(bash_state, &entry.file_path),
206                source: Box::new(source),
207            }
208        }
209        source @ (WinxError::FileNotFound { .. }
210        | WinxError::FileReadRequired { .. }
211        | WinxError::FileOperationDenied { .. }
212        | WinxError::ConcurrentFileModification { .. }) => WinxError::MultiFilePlanError {
213            index: index + 1,
214            path: resolved_entry_path(bash_state, &entry.file_path),
215            source: Box::new(source),
216        },
217        WinxError::SearchReplaceSyntaxError(message) => {
218            WinxError::SearchReplaceSyntaxError(format!("{context}: {message}"))
219        }
220        WinxError::SearchReplaceSyntaxErrorDetailed {
221            message,
222            line_number,
223            block_type,
224            suggestions,
225        } => WinxError::SearchReplaceSyntaxErrorDetailed {
226            message: format!("{context}: {message}"),
227            line_number,
228            block_type,
229            suggestions,
230        },
231        other => other,
232    }
233}
234
235fn resolved_entry_path(bash_state: &BashState, value: &str) -> PathBuf {
236    let expanded = crate::utils::path::expand_user(value);
237    let path = if Path::new(&expanded).is_absolute() {
238        PathBuf::from(expanded)
239    } else {
240        bash_state.cwd.join(expanded)
241    };
242    path.canonicalize().unwrap_or(path)
243}