winx_code_agent/tools/
multi_file_edit.rs1use 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
28const 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 {
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 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
98fn apply_batch(bash_state: &mut BashState, files: &[FileEditEntry]) -> Result<String> {
102 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 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 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
176fn 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}