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` -> `write_no_follow`). 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::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
27/// Upper bound on files per batch. The whole batch holds the `bash_state` lock
28/// across its (synchronous) file IO, so a huge batch would block the executor
29/// and other sessions for a long time. A real multi-file refactor is well under
30/// this; the cap is a guard against a pathological request.
31const 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    // PHASE 1: plan every file (validate + compute new content) with NO writes.
61    // Any failure aborts the whole batch having touched nothing on disk.
62    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            // Plan failures (bad path, mode gate, stale/unread file, SEARCH miss)
72            // are all caused by the agent's input, so keep them client-classified
73            // (invalid_request) rather than wrapping in a server-error variant.
74            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    // Reject duplicate targets, checked on the RESOLVED path (so `a.txt` and its
84    // absolute form can't both slip through and clobber each other): two entries
85    // for the same file don't compose - the second is computed from the original,
86    // not the first's result, so it would silently overwrite the first.
87    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    // PHASE 2: commit sequentially. Each write is individually atomic (temp +
99    // rename). On the first failure, stop and report honestly without rolling
100    // back already-written files.
101    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}