winx_code_agent/tools/
multi_file_edit.rs1use std::collections::HashSet;
17use std::sync::Arc;
18
19use tokio::sync::Mutex;
20use tracing::instrument;
21
22use crate::errors::{Result, WinxError};
23use crate::state::bash_state::BashState;
24use crate::tools::file_write_or_edit::{commit_edit, plan_edit};
25use crate::types::{normalize_thread_id, MultiFileEdit};
26
27const MAX_FILES_PER_BATCH: usize = 100;
32
33#[instrument(level = "info", skip(bash_state_arc, multi))]
34pub async fn handle_tool_call(
35 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
36 multi: MultiFileEdit,
37) -> Result<String> {
38 let mut bash_state_guard = bash_state_arc.lock().await;
39 let bash_state = bash_state_guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
40
41 let thread_id = normalize_thread_id(&multi.thread_id);
42 if thread_id != bash_state.current_thread_id {
43 return Err(WinxError::ThreadIdMismatch(thread_id));
44 }
45
46 if multi.files.len() < 2 {
47 return Err(WinxError::ArgumentParseError(
48 "MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
49 .to_string(),
50 ));
51 }
52 if multi.files.len() > MAX_FILES_PER_BATCH {
53 return Err(WinxError::ArgumentParseError(format!(
54 "MultiFileEdit is limited to {MAX_FILES_PER_BATCH} files per batch (got {}); split the \
55 change into smaller batches.",
56 multi.files.len()
57 )));
58 }
59
60 let mut planned = Vec::with_capacity(multi.files.len());
63 for (index, entry) in multi.files.iter().enumerate() {
64 let edit = plan_edit(
65 bash_state,
66 &entry.file_path,
67 entry.percentage_to_change,
68 &entry.text_or_search_replace_blocks,
69 )
70 .map_err(|e| {
71 WinxError::ArgumentParseError(format!(
75 "MultiFileEdit aborted before writing anything - file {} ({}) failed validation: {e}",
76 index + 1,
77 entry.file_path
78 ))
79 })?;
80 planned.push(edit);
81 }
82
83 let mut seen = HashSet::with_capacity(planned.len());
88 for edit in &planned {
89 if !seen.insert(edit.target()) {
90 return Err(WinxError::ArgumentParseError(format!(
91 "MultiFileEdit targets '{}' more than once; edits to the same file don't compose - \
92 combine them into a single entry.",
93 edit.target()
94 )));
95 }
96 }
97
98 let total = planned.len();
102 let mut summaries = Vec::with_capacity(total);
103 for (committed, edit) in planned.into_iter().enumerate() {
104 let target = edit.target().to_string();
105 match commit_edit(bash_state, edit) {
106 Ok(summary) => summaries.push(format!("[{target}]\n{summary}")),
107 Err(e) => {
108 return Err(WinxError::CommandExecutionError(format!(
109 "MultiFileEdit: committed {committed} of {total} files, then failed writing \
110 {target}: {e}\nThe {committed} already-written file(s) were NOT rolled back. \
111 Re-read the affected files and retry the rest."
112 )));
113 }
114 }
115 }
116
117 Ok(format!("MultiFileEdit applied all {total} edits:\n\n{}", summaries.join("\n\n")))
118}