1use std::sync::Arc;
4
5use tokio::sync::Mutex;
6use tracing::instrument;
7
8use crate::errors::{Result, WinxError};
9use crate::state::bash_state::BashState;
10use crate::types::{ApplyPatch, LinePatch};
11
12const MAX_PATCHES: usize = 256;
13
14#[derive(Debug)]
15pub struct ApplyPatchOutcome {
16 pub text: String,
17 pub revision: String,
18}
19
20#[instrument(level = "info", skip(bash_state_arc, request))]
21pub async fn handle_tool_call(
22 bash_state_arc: &Arc<Mutex<Option<BashState>>>,
23 request: ApplyPatch,
24) -> Result<ApplyPatchOutcome> {
25 let outcome = crate::tools::edit_files::handle_legacy_tool(
26 bash_state_arc,
27 crate::tools::edit_files::EditSurface::ApplyPatch,
28 request,
29 )
30 .await?;
31 let revision = outcome.revisions.into_iter().next().ok_or_else(|| {
32 WinxError::CommandExecutionError(
33 "ApplyPatch unified engine returned no committed revision".to_string(),
34 )
35 })?;
36 Ok(ApplyPatchOutcome { text: outcome.text, revision })
37}
38
39pub(crate) fn validate_revision(revision: &str) -> Result<()> {
40 let hash = revision.strip_prefix("sha256:").unwrap_or_default();
41 if hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit()) {
42 return Err(WinxError::ParameterValidationError {
43 field: "expected_revision".to_string(),
44 message: "must be the exact sha256:<64 lowercase hex> token returned by ReadFiles"
45 .to_string(),
46 });
47 }
48 if hash.bytes().any(|byte| byte.is_ascii_uppercase()) {
49 return Err(WinxError::ParameterValidationError {
50 field: "expected_revision".to_string(),
51 message: "revision tokens are case-sensitive; copy the ReadFiles value exactly"
52 .to_string(),
53 });
54 }
55 Ok(())
56}
57
58pub(crate) fn validate_patch_count(patches: &[LinePatch]) -> Result<()> {
59 if patches.is_empty() || patches.len() > MAX_PATCHES {
60 return Err(WinxError::ParameterValidationError {
61 field: "patches".to_string(),
62 message: format!("must contain between 1 and {MAX_PATCHES} patches"),
63 });
64 }
65 Ok(())
66}
67
68pub(crate) fn required_read_ranges(patches: &[LinePatch]) -> Vec<(usize, usize)> {
69 patches
70 .iter()
71 .map(|patch| {
72 if patch.delete_lines == 0 {
73 (patch.start_line, patch.start_line)
74 } else {
75 (
76 patch.start_line,
77 patch.start_line.saturating_add(patch.delete_lines).saturating_sub(1),
78 )
79 }
80 })
81 .collect()
82}
83
84pub(crate) fn apply_line_patches(content: &str, patches: &[LinePatch]) -> Result<String> {
85 let spans = line_spans(content);
86 validate_patch_coordinates(patches, spans.len())?;
87 let mut output = content.to_string();
88 for patch in patches.iter().rev() {
89 let start_index = patch.start_line - 1;
90 let byte_start = spans.get(start_index).map_or(content.len(), |(start, _)| *start);
91 let byte_end = if patch.delete_lines == 0 {
92 byte_start
93 } else {
94 spans[start_index + patch.delete_lines - 1].1
95 };
96 output.replace_range(byte_start..byte_end, &patch.replacement);
97 }
98 Ok(output)
99}
100
101fn validate_patch_coordinates(patches: &[LinePatch], total_lines: usize) -> Result<()> {
102 let mut previous_start = 0;
103 let mut previous_end = 0;
104 for (index, patch) in patches.iter().enumerate() {
105 if patch.start_line == 0 || patch.start_line > total_lines.saturating_add(1) {
106 return invalid_patch(index, format!("start_line must be in 1..={}", total_lines + 1));
107 }
108 let start = patch.start_line - 1;
109 let end = start.checked_add(patch.delete_lines).ok_or_else(|| {
110 WinxError::ParameterValidationError {
111 field: format!("patches[{index}].delete_lines"),
112 message: "line range overflow".to_string(),
113 }
114 })?;
115 if end > total_lines {
116 return invalid_patch(index, "delete_lines extends past the original file".to_string());
117 }
118 if index > 0 && (start < previous_end || start == previous_start) {
119 return invalid_patch(
120 index,
121 "patches must be strictly ordered and non-overlapping in the original revision"
122 .to_string(),
123 );
124 }
125 previous_start = start;
126 previous_end = end;
127 }
128 Ok(())
129}
130
131fn invalid_patch<T>(index: usize, message: String) -> Result<T> {
132 Err(WinxError::ParameterValidationError { field: format!("patches[{index}]"), message })
133}
134
135fn line_spans(content: &str) -> Vec<(usize, usize)> {
136 let mut spans = Vec::new();
137 let mut start = 0;
138 for (index, byte) in content.bytes().enumerate() {
139 if byte == b'\n' {
140 spans.push((start, index + 1));
141 start = index + 1;
142 }
143 }
144 if start < content.len() {
145 spans.push((start, content.len()));
146 }
147 spans
148}
149
150#[cfg(test)]
151mod tests {
152 use std::sync::Arc;
153
154 use super::*;
155 use tokio::sync::Mutex;
156
157 use crate::errors::ReadRequirement;
158 use crate::types::{normalize_thread_id, Modes, ReadFiles};
159
160 fn patch(start_line: usize, delete_lines: usize, replacement: &str) -> LinePatch {
161 LinePatch { start_line, delete_lines, replacement: replacement.to_string() }
162 }
163
164 #[test]
165 fn applies_multiple_original_revision_patches_from_the_end() -> Result<()> {
166 let output = apply_line_patches(
167 "one\ntwo\nthree\nfour\n",
168 &[patch(2, 1, "TWO\n"), patch(4, 0, "before four\n")],
169 )?;
170 assert_eq!(output, "one\nTWO\nthree\nbefore four\nfour\n");
171 Ok(())
172 }
173
174 #[test]
175 fn supports_append_and_empty_file_insert() -> Result<()> {
176 assert_eq!(apply_line_patches("one\n", &[patch(2, 0, "two\n")])?, "one\ntwo\n");
177 assert_eq!(apply_line_patches("", &[patch(1, 0, "first\n")])?, "first\n");
178 Ok(())
179 }
180
181 #[test]
182 fn rejects_overlapping_or_out_of_order_coordinates() {
183 assert!(apply_line_patches("one\ntwo\nthree\n", &[patch(2, 2, "x\n"), patch(3, 1, "y\n")])
184 .is_err());
185 assert!(apply_line_patches("one\n", &[patch(3, 0, "x")]).is_err());
186 }
187
188 fn state_for(root: &std::path::Path, thread_id: &str) -> Arc<Mutex<Option<BashState>>> {
189 let mut state = BashState::new();
190 state.initialized = true;
191 state.cwd = root.to_path_buf();
192 state.workspace_root = root.to_path_buf();
193 state.current_thread_id = normalize_thread_id(thread_id);
194 state.mode = Modes::Wcgw;
195 Arc::new(Mutex::new(Some(state)))
196 }
197
198 #[tokio::test]
199 async fn revision_patch_uses_only_visible_lines_and_stale_retry_is_safe() -> Result<()> {
200 let temp = tempfile::tempdir()?;
201 let path = temp.path().join("sample.txt");
202 std::fs::write(&path, "one\ntwo\nthree\n")?;
203 let state = state_for(temp.path(), "patch-test");
204 let read = crate::tools::read_files::handle_tool_call_detailed(
205 &state,
206 ReadFiles {
207 file_paths: vec![path.to_string_lossy().into_owned()],
208 thread_id: "patch-test".to_string(),
209 start_line_nums: vec![Some(2)],
210 end_line_nums: vec![Some(2)],
211 },
212 )
213 .await?;
214 let revision = read.files[0].revision.clone();
215
216 let outcome = handle_tool_call(
217 &state,
218 ApplyPatch {
219 file_path: path.to_string_lossy().into_owned(),
220 expected_revision: revision.clone(),
221 patches: vec![patch(2, 1, "TWO\n")],
222 thread_id: "patch-test".to_string(),
223 },
224 )
225 .await?;
226 assert_ne!(outcome.revision, revision);
227 assert_eq!(std::fs::read_to_string(&path)?, "one\nTWO\nthree\n");
228 assert!(state
229 .lock()
230 .await
231 .as_ref()
232 .is_some_and(|state| state.whitelist_for_overwrite.is_empty()));
233
234 let stale_result = handle_tool_call(
235 &state,
236 ApplyPatch {
237 file_path: path.to_string_lossy().into_owned(),
238 expected_revision: revision,
239 patches: vec![patch(2, 1, "WRONG\n")],
240 thread_id: "patch-test".to_string(),
241 },
242 )
243 .await;
244 assert!(matches!(stale_result, Err(WinxError::FileRevisionMismatch { .. })));
245 assert_eq!(std::fs::read_to_string(&path)?, "one\nTWO\nthree\n");
246 Ok(())
247 }
248
249 #[tokio::test]
250 async fn patch_cannot_touch_an_unread_line() -> Result<()> {
251 let temp = tempfile::tempdir()?;
252 let path = temp.path().join("sample.txt");
253 std::fs::write(&path, "one\ntwo\nthree\n")?;
254 let state = state_for(temp.path(), "coverage-test");
255 let read = crate::tools::read_files::handle_tool_call_detailed(
256 &state,
257 ReadFiles {
258 file_paths: vec![path.to_string_lossy().into_owned()],
259 thread_id: "coverage-test".to_string(),
260 start_line_nums: vec![Some(2)],
261 end_line_nums: vec![Some(2)],
262 },
263 )
264 .await?;
265 let patch_result = handle_tool_call(
266 &state,
267 ApplyPatch {
268 file_path: path.to_string_lossy().into_owned(),
269 expected_revision: read.files[0].revision.clone(),
270 patches: vec![patch(3, 1, "THREE\n")],
271 thread_id: "coverage-test".to_string(),
272 },
273 )
274 .await;
275 assert!(matches!(
276 patch_result,
277 Err(WinxError::FileReadRequired { reason: ReadRequirement::InsufficientCoverage, .. })
278 ));
279 assert_eq!(std::fs::read_to_string(path)?, "one\ntwo\nthree\n");
280 Ok(())
281 }
282}