1use anyhow::{Context, Result, anyhow};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum PatchOperation {
13 AddFile {
14 path: String,
15 content: String,
16 },
17 DeleteFile {
18 path: String,
19 },
20 UpdateFile {
21 path: String,
22 new_path: Option<String>,
23 hunks: Vec<PatchHunk>,
24 },
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PatchHunk {
30 pub header: Option<String>,
31 pub lines: Vec<PatchLine>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum PatchLine {
37 Context(String),
38 Remove(String),
39 Add(String),
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Patch {
45 pub operations: Vec<PatchOperation>,
46}
47
48#[derive(Debug, Deserialize, Serialize)]
50pub struct ApplyPatchInput {
51 pub input: String,
52}
53
54impl Patch {
55 pub fn parse(input: &str) -> Result<Self> {
57 let mut lines = input.lines().peekable();
58 let mut operations = Vec::new();
59
60 while let Some(line) = lines.next() {
62 if line.trim() == "*** Begin Patch" {
63 break;
64 }
65 }
66
67 while let Some(line) = lines.next() {
69 if line.trim() == "*** End Patch" {
70 break;
71 }
72
73 if line.starts_with("*** Add File: ") {
74 let path = line[13..].trim().to_string();
75 let mut content_lines = Vec::new();
76
77 while let Some(next_line) = lines.peek() {
79 if next_line.starts_with("*** ") {
80 break;
82 }
83 if next_line.starts_with("+") {
84 content_lines.push(next_line[1..].to_string());
85 lines.next(); } else {
87 break;
89 }
90 }
91
92 operations.push(PatchOperation::AddFile {
93 path,
94 content: content_lines.join("\n"),
95 });
96 } else if line.starts_with("*** Delete File: ") {
97 let path = line[16..].trim().to_string();
98 operations.push(PatchOperation::DeleteFile { path });
99 } else if line.starts_with("*** Update File: ") {
100 let path = line[17..].trim().to_string();
101 let mut new_path = None;
102 let mut hunks = Vec::new();
103
104 if let Some(next_line) = lines.peek() {
106 if next_line.starts_with("*** Move to: ") {
107 let move_line = lines.next().unwrap(); new_path = Some(move_line[13..].trim().to_string());
109 }
110 }
111
112 let mut current_hunk = None;
114 while let Some(next_line) = lines.peek() {
115 if next_line.starts_with("*** ") {
116 break;
118 }
119
120 if next_line.starts_with("@@") {
121 if let Some(hunk) = current_hunk.take() {
123 hunks.push(hunk);
124 }
125
126 let header = if next_line.len() > 2 {
128 Some(next_line[3..].trim().to_string())
129 } else {
130 None
131 };
132 current_hunk = Some(PatchHunk {
133 header,
134 lines: Vec::new(),
135 });
136 lines.next(); } else if next_line.starts_with("*** End of File") {
138 lines.next(); break;
140 } else if let Some(ref mut hunk) = current_hunk {
141 let line_content = if next_line.len() > 1 {
143 next_line[1..].to_string()
144 } else {
145 String::new()
146 };
147
148 let patch_line = match next_line.chars().next() {
149 Some(' ') => PatchLine::Context(line_content),
150 Some('-') => PatchLine::Remove(line_content),
151 Some('+') => PatchLine::Add(line_content),
152 _ => PatchLine::Context(next_line.to_string()),
153 };
154
155 hunk.lines.push(patch_line);
156 lines.next(); } else {
158 break;
160 }
161 }
162
163 if let Some(hunk) = current_hunk.take() {
165 hunks.push(hunk);
166 }
167
168 operations.push(PatchOperation::UpdateFile {
169 path,
170 new_path,
171 hunks,
172 });
173 }
174 }
175
176 Ok(Patch { operations })
177 }
178
179 pub async fn apply(&self, root: &Path) -> Result<Vec<String>> {
181 let mut results = Vec::new();
182
183 for operation in &self.operations {
184 match operation {
185 PatchOperation::AddFile { path, content } => {
186 let full_path = root.join(path);
187 if let Some(parent) = full_path.parent() {
188 tokio::fs::create_dir_all(parent).await.context(format!(
189 "failed to create parent directories: {}",
190 parent.display()
191 ))?;
192 }
193 tokio::fs::write(&full_path, content)
194 .await
195 .context(format!("failed to write file: {}", full_path.display()))?;
196 results.push(format!("Added file: {}", path));
197 }
198 PatchOperation::DeleteFile { path } => {
199 let full_path = root.join(path);
200 if full_path.exists() {
201 if full_path.is_dir() {
202 tokio::fs::remove_dir_all(&full_path)
203 .await
204 .context(format!(
205 "failed to delete directory: {}",
206 full_path.display()
207 ))?;
208 } else {
209 tokio::fs::remove_file(&full_path).await.context(format!(
210 "failed to delete file: {}",
211 full_path.display()
212 ))?;
213 }
214 results.push(format!("Deleted file: {}", path));
215 } else {
216 results.push(format!("File not found, skipped deletion: {}", path));
217 }
218 }
219 PatchOperation::UpdateFile {
220 path,
221 new_path,
222 hunks,
223 } => {
224 let full_path = root.join(path);
225
226 let existing_content = if full_path.exists() {
228 tokio::fs::read_to_string(&full_path)
229 .await
230 .context(format!("failed to read file: {}", full_path.display()))?
231 } else {
232 return Err(anyhow!("File not found: {}", path));
233 };
234
235 let new_content = Self::apply_hunks_to_content(&existing_content, hunks)?;
237
238 let target_path = if let Some(new_path_str) = new_path {
240 let new_full_path = root.join(new_path_str);
241 if let Some(parent) = new_full_path.parent() {
242 tokio::fs::create_dir_all(parent).await.context(format!(
243 "failed to create parent directories: {}",
244 parent.display()
245 ))?;
246 }
247 if full_path.exists() {
249 tokio::fs::remove_file(&full_path).await.context(format!(
250 "failed to remove old file: {}",
251 full_path.display()
252 ))?;
253 }
254 new_full_path
255 } else {
256 full_path
257 };
258
259 tokio::fs::write(&target_path, new_content)
260 .await
261 .context(format!("failed to write file: {}", target_path.display()))?;
262
263 if let Some(new_path_str) = new_path {
264 results.push(format!("Updated file: {} -> {}", path, new_path_str));
265 } else {
266 results.push(format!("Updated file: {}", path));
267 }
268 }
269 }
270 }
271
272 Ok(results)
273 }
274
275 fn apply_hunks_to_content(content: &str, hunks: &[PatchHunk]) -> Result<String> {
277 let original_lines: Vec<&str> = content.lines().collect();
278 let ends_with_newline = content.ends_with('\n');
279 let mut lines: Vec<String> = original_lines.into_iter().map(|s| s.to_string()).collect();
280
281 for hunk in hunks.iter().rev() {
283 let mut line_index = 0;
286
287 if !hunk.lines.is_empty() {
289 for (idx, line) in hunk.lines.iter().enumerate() {
291 match line {
292 PatchLine::Remove(text) | PatchLine::Add(text) => {
293 if let Some(pos) = lines.iter().position(|l| l == text) {
295 line_index = pos;
296 let context_lines_before = hunk.lines[..idx]
298 .iter()
299 .filter(|l| matches!(l, PatchLine::Context(_)))
300 .count();
301 line_index = line_index.saturating_sub(context_lines_before);
302 }
303 break;
304 }
305 _ => continue,
306 }
307 }
308 }
309
310 let mut i = line_index;
312 for line in &hunk.lines {
313 match line {
314 PatchLine::Context(text) => {
315 if i < lines.len() && &lines[i] == text {
317 i += 1;
318 } else {
319 i += 1;
322 }
323 }
324 PatchLine::Remove(text) => {
325 if i < lines.len() && &lines[i] == text {
327 lines.remove(i);
328 } else {
330 return Err(anyhow!("Context mismatch when removing line: {}", text));
331 }
332 }
333 PatchLine::Add(text) => {
334 lines.insert(i, text.clone());
336 i += 1;
337 }
338 }
339 }
340 }
341
342 let result = lines.join("\n");
344 if ends_with_newline && !result.is_empty() && !result.ends_with('\n') {
345 Ok(format!("{}\n", result))
346 } else {
347 Ok(result)
348 }
349 }
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use tempfile::TempDir;
356
357 #[test]
358 fn test_parse_simple_patch() {
359 let patch_str = r#"*** Begin Patch
360*** Add File: test.txt
361+Hello, world!
362+This is a test file.
363*** End Patch"#;
364
365 let patch = Patch::parse(patch_str).unwrap();
366 assert_eq!(patch.operations.len(), 1);
367
368 match &patch.operations[0] {
369 PatchOperation::AddFile { path, content } => {
370 assert_eq!(path, "test.txt");
371 assert_eq!(content, "Hello, world!\nThis is a test file.");
372 }
373 _ => panic!("Expected AddFile operation"),
374 }
375 }
376
377 #[tokio::test]
378 async fn test_apply_add_file() -> Result<()> {
379 let temp_dir = TempDir::new()?;
380 let workspace = temp_dir.path().to_path_buf();
381
382 let patch_str = r#"*** Begin Patch
383*** Add File: hello.txt
384+Hello, world!
385+This is a test.
386*** End Patch"#;
387
388 let patch = Patch::parse(patch_str)?;
389 let results = patch.apply(&workspace).await?;
390
391 assert_eq!(results.len(), 1);
392 assert!(results[0].contains("Added file: hello.txt"));
393
394 let file_path = workspace.join("hello.txt");
395 assert!(file_path.exists());
396
397 let content = tokio::fs::read_to_string(&file_path).await?;
398 assert_eq!(content, "Hello, world!\nThis is a test.");
399
400 Ok(())
401 }
402
403 #[tokio::test]
404 async fn test_apply_delete_file() -> Result<()> {
405 let temp_dir = TempDir::new()?;
406 let workspace = temp_dir.path().to_path_buf();
407
408 let file_path = workspace.join("to_delete.txt");
410 tokio::fs::write(&file_path, "This file will be deleted").await?;
411
412 let patch_str = r#"*** Begin Patch
413*** Delete File: to_delete.txt
414*** End Patch"#;
415
416 let patch = Patch::parse(patch_str)?;
417 let results = patch.apply(&workspace).await?;
418
419 assert_eq!(results.len(), 1);
420 assert!(results[0].contains("Deleted file: to_delete.txt"));
421 assert!(!file_path.exists());
422
423 Ok(())
424 }
425}