use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::instrument;
use crate::errors::{Result, WinxError};
use crate::state::bash_state::BashState;
use crate::tools::file_write_or_edit::{commit_edit, plan_edit};
use crate::types::{normalize_thread_id, FileEditEntry, MultiFileEdit};
const MAX_FILES_PER_BATCH: usize = 100;
#[instrument(level = "info", skip(bash_state_arc, multi))]
pub async fn handle_tool_call(
bash_state_arc: &Arc<Mutex<Option<BashState>>>,
multi: MultiFileEdit,
) -> Result<String> {
let mut bash_state_guard = bash_state_arc.lock().await;
{
let bash_state = bash_state_guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
let thread_id = normalize_thread_id(&multi.thread_id);
if thread_id != bash_state.current_thread_id {
return Err(WinxError::ThreadIdMismatch(thread_id));
}
}
if multi.files.len() < 2 {
return Err(WinxError::ArgumentParseError(
"MultiFileEdit needs at least 2 files; use FileWriteOrEdit for a single file."
.to_string(),
));
}
if multi.files.len() > MAX_FILES_PER_BATCH {
return Err(WinxError::ArgumentParseError(format!(
"MultiFileEdit is limited to {MAX_FILES_PER_BATCH} files per batch (got {}); split the \
change into smaller batches.",
multi.files.len()
)));
}
let mut state = bash_state_guard.take().ok_or(WinxError::BashStateNotInitialized)?;
let files = multi.files;
let (state, result) = tokio::task::spawn_blocking(move || {
let r = apply_batch(&mut state, &files);
(state, r)
})
.await
.map_err(|e| WinxError::CommandExecutionError(format!("MultiFileEdit task failed: {e}")))?;
*bash_state_guard = Some(state);
result
}
fn apply_batch(bash_state: &mut BashState, files: &[FileEditEntry]) -> Result<String> {
let mut planned = Vec::with_capacity(files.len());
for (index, entry) in files.iter().enumerate() {
let edit = plan_edit(
bash_state,
&entry.file_path,
entry.percentage_to_change,
&entry.text_or_search_replace_blocks,
)
.map_err(|e| {
WinxError::ArgumentParseError(format!(
"MultiFileEdit aborted before writing anything - file {} ({}) failed validation: {e}",
index + 1,
entry.file_path
))
})?;
planned.push(edit);
}
let mut seen = HashSet::with_capacity(planned.len());
for edit in &planned {
if !seen.insert(edit.target()) {
return Err(WinxError::ArgumentParseError(format!(
"MultiFileEdit targets '{}' more than once; edits to the same file don't compose - \
combine them into a single entry.",
edit.target()
)));
}
}
let total = planned.len();
let mut summaries = Vec::with_capacity(total);
for (committed, edit) in planned.into_iter().enumerate() {
let target = edit.target().to_string();
match commit_edit(bash_state, edit) {
Ok(summary) => summaries.push(format!("[{target}]\n{summary}")),
Err(e) => {
return Err(WinxError::CommandExecutionError(format!(
"MultiFileEdit: committed {committed} of {total} files, then failed writing \
{target}: {e}\nThe {committed} already-written file(s) were NOT rolled back. \
Re-read the affected files and retry the rest."
)));
}
}
}
Ok(format!("MultiFileEdit applied all {total} edits:\n\n{}", summaries.join("\n\n")))
}