vtcode_core/tools/
apply_patch.rs

1//! Patch application module implementing the OpenAI Codex patch format
2//!
3//! This module provides functionality to parse and apply patches in the format
4//! used by OpenAI Codex, which is designed to be easy to parse and safe to apply.
5
6use anyhow::{Context, Result, anyhow};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10/// Represents a patch operation
11#[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/// Represents a hunk in a patch
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PatchHunk {
30    pub header: Option<String>,
31    pub lines: Vec<PatchLine>,
32}
33
34/// Represents a line in a patch hunk
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum PatchLine {
37    Context(String),
38    Remove(String),
39    Add(String),
40}
41
42/// Represents a complete patch
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Patch {
45    pub operations: Vec<PatchOperation>,
46}
47
48/// Input structure for the apply_patch tool
49#[derive(Debug, Deserialize, Serialize)]
50pub struct ApplyPatchInput {
51    pub input: String,
52}
53
54impl Patch {
55    /// Parse a patch from a string
56    pub fn parse(input: &str) -> Result<Self> {
57        let mut lines = input.lines().peekable();
58        let mut operations = Vec::new();
59
60        // Skip until we find the begin marker
61        while let Some(line) = lines.next() {
62            if line.trim() == "*** Begin Patch" {
63                break;
64            }
65        }
66
67        // Parse operations until we find the end marker
68        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                // Collect all lines that start with "+"
78                while let Some(next_line) = lines.peek() {
79                    if next_line.starts_with("*** ") {
80                        // Next operation
81                        break;
82                    }
83                    if next_line.starts_with("+") {
84                        content_lines.push(next_line[1..].to_string());
85                        lines.next(); // consume the line
86                    } else {
87                        // Unexpected line, break
88                        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                // Check for move operation
105                if let Some(next_line) = lines.peek() {
106                    if next_line.starts_with("*** Move to: ") {
107                        let move_line = lines.next().unwrap(); // consume the line
108                        new_path = Some(move_line[13..].trim().to_string());
109                    }
110                }
111
112                // Parse hunks
113                let mut current_hunk = None;
114                while let Some(next_line) = lines.peek() {
115                    if next_line.starts_with("*** ") {
116                        // Next operation
117                        break;
118                    }
119
120                    if next_line.starts_with("@@") {
121                        // Save previous hunk if exists
122                        if let Some(hunk) = current_hunk.take() {
123                            hunks.push(hunk);
124                        }
125
126                        // Start new hunk
127                        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(); // consume the line
137                    } else if next_line.starts_with("*** End of File") {
138                        lines.next(); // consume the line
139                        break;
140                    } else if let Some(ref mut hunk) = current_hunk {
141                        // Add line to current hunk
142                        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(); // consume the line
157                    } else {
158                        // Line outside of hunk, break
159                        break;
160                    }
161                }
162
163                // Save last hunk if exists
164                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    /// Apply the patch to the file system
180    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                    // Read existing content
227                    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                    // Apply hunks to content
236                    let new_content = Self::apply_hunks_to_content(&existing_content, hunks)?;
237
238                    // Write updated content
239                    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                        // Remove old file if path changed
248                        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    /// Apply hunks to content
276    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        // Apply hunks in reverse order to maintain line numbers
282        for hunk in hunks.iter().rev() {
283            // Find the position where this hunk should be applied
284            // For simplicity, we'll just try to match the first few lines
285            let mut line_index = 0;
286
287            // Try to find where the hunk should be applied by matching context
288            if !hunk.lines.is_empty() {
289                // Look for the first non-context line to match
290                for (idx, line) in hunk.lines.iter().enumerate() {
291                    match line {
292                        PatchLine::Remove(text) | PatchLine::Add(text) => {
293                            // Try to find this line in the content
294                            if let Some(pos) = lines.iter().position(|l| l == text) {
295                                line_index = pos;
296                                // Adjust for context lines before this
297                                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            // Apply the lines in this hunk
311            let mut i = line_index;
312            for line in &hunk.lines {
313                match line {
314                    PatchLine::Context(text) => {
315                        // For context lines, verify they match
316                        if i < lines.len() && &lines[i] == text {
317                            i += 1;
318                        } else {
319                            // Context mismatch, but we'll continue for now
320                            // A more sophisticated implementation would handle this better
321                            i += 1;
322                        }
323                    }
324                    PatchLine::Remove(text) => {
325                        // Remove the line if it matches
326                        if i < lines.len() && &lines[i] == text {
327                            lines.remove(i);
328                            // Don't increment i since we removed a line
329                        } else {
330                            return Err(anyhow!("Context mismatch when removing line: {}", text));
331                        }
332                    }
333                    PatchLine::Add(text) => {
334                        // Add the line at the current position
335                        lines.insert(i, text.clone());
336                        i += 1;
337                    }
338                }
339            }
340        }
341
342        // Join lines with newlines, preserving the original trailing newline
343        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        // Create a file to delete
409        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}