vtcode_core/tools/editing/patch/
mod.rs1use std::path::Path;
2
3use anyhow::anyhow;
4
5mod applicator;
6pub(crate) use applicator::inspection_replacements;
7mod error;
8mod matcher;
9mod parser;
10mod path;
11mod semantic;
12
13#[doc(hidden)]
14pub(crate) use crate::tools::ast_grep_binary::is_binary_override_missing;
15pub use crate::tools::ast_grep_binary::{AstGrepBinaryOverrideGuard, set_ast_grep_binary_override_for_tests};
16pub use error::PatchError;
17pub(crate) use semantic::resolve_ast_grep_binary_path;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum PatchLine {
22 Context(String),
23 Addition(String),
24 Removal(String),
25}
26
27impl PatchLine {
28 pub fn as_str(&self) -> &str {
29 match self {
30 PatchLine::Context(text) | PatchLine::Addition(text) | PatchLine::Removal(text) => text,
31 }
32 }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PatchChunk {
38 pub change_context: Option<String>,
39 pub lines: Vec<PatchLine>,
40 pub is_end_of_file: bool,
41}
42
43impl PatchChunk {
44 pub fn lines(&self) -> &[PatchLine] {
45 &self.lines
46 }
47
48 pub fn change_context(&self) -> Option<&str> {
49 self.change_context.as_deref()
50 }
51
52 pub fn is_end_of_file(&self) -> bool {
53 self.is_end_of_file
54 }
55
56 fn to_segments(&self) -> (Vec<String>, Vec<String>) {
57 let cap = self.lines.len();
58 let mut old_lines = Vec::with_capacity(cap);
59 let mut new_lines = Vec::with_capacity(cap);
60
61 for line in &self.lines {
62 match line {
63 PatchLine::Context(text) => {
64 old_lines.push(text.clone());
65 new_lines.push(text.clone());
66 }
67 PatchLine::Addition(text) => {
68 new_lines.push(text.clone());
69 }
70 PatchLine::Removal(text) => {
71 old_lines.push(text.clone());
72 }
73 }
74 }
75
76 (old_lines, new_lines)
77 }
78
79 fn has_old_lines(&self) -> bool {
80 self.lines
81 .iter()
82 .any(|line| matches!(line, PatchLine::Context(_) | PatchLine::Removal(_)))
83 }
84
85 pub fn parse_line_number(&self) -> Option<usize> {
86 let ctx = self.change_context()?;
87 let parts: Vec<&str> = ctx.split_whitespace().collect();
89 let old_part = if !parts.is_empty() && parts[0].starts_with('-') {
90 Some(parts[0])
91 } else if parts.len() >= 2 && parts[1].starts_with('-') {
92 Some(parts[1])
93 } else {
94 None
95 }?;
96
97 let range_str = old_part.strip_prefix('-')?;
98 let range_parts: Vec<&str> = range_str.split(',').collect();
99 let start_str = range_parts.first()?;
100 start_str.parse::<usize>().ok()
101 }
102}
103
104pub type PatchHunk = PatchChunk;
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum PatchOperation {
109 AddFile {
110 path: String,
111 content: String,
112 },
113 DeleteFile {
114 path: String,
115 },
116 UpdateFile {
117 path: String,
118 new_path: Option<String>,
119 chunks: Vec<PatchChunk>,
120 },
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct Patch {
126 operations: Vec<PatchOperation>,
127}
128
129impl Patch {
130 pub fn parse(input: &str) -> anyhow::Result<Self> {
131 let operations = parser::parse(input).map_err(|err| anyhow!(err))?;
132 Ok(Self { operations })
133 }
134
135 pub fn operations(&self) -> &[PatchOperation] {
136 &self.operations
137 }
138
139 pub fn is_empty(&self) -> bool {
140 self.operations.is_empty()
141 }
142
143 pub fn into_operations(self) -> Vec<PatchOperation> {
144 self.operations
145 }
146
147 pub async fn apply(&self, root: &Path) -> anyhow::Result<Vec<String>> {
148 applicator::apply(root, &self.operations).await.map_err(|err| anyhow!(err))
149 }
150}
151
152pub async fn render_patch_update_content(
153 source_path: &Path,
154 content: &str,
155 chunks: &[PatchChunk],
156 path: &str,
157) -> anyhow::Result<String> {
158 applicator::render_updated_content(source_path, content, chunks, path)
159 .await
160 .map_err(|err| anyhow!(err))
161}
162
163#[must_use]
177pub fn looks_like_vte_patch(text: &str) -> bool {
178 let trimmed = text.trim_start();
179 trimmed.starts_with("*** Begin Patch")
180 || trimmed.starts_with("*** Update File:")
181 || trimmed.starts_with("*** Add File:")
182 || trimmed.starts_with("*** Delete File:")
183}
184
185#[must_use]
192pub fn looks_like_unified_diff(text: &str) -> bool {
193 let trimmed = text.trim_start();
194 if trimmed.starts_with("diff --git") {
195 return true;
196 }
197 let mut saw_old = false;
200 for line in trimmed.lines().take(12) {
201 let lt = line.trim_start();
202 if lt.starts_with("--- ") {
203 saw_old = true;
204 } else if saw_old && lt.starts_with("+++ ") {
205 return true;
206 }
207 }
208 false
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use tempfile::TempDir;
215
216 #[test]
217 fn parse_add_file() {
218 let patch = Patch::parse("*** Begin Patch\n*** Add File: hello.txt\n+hello\n*** End Patch").unwrap();
219 assert_eq!(patch.operations().len(), 1);
220 matches!(patch.operations()[0], PatchOperation::AddFile { .. });
221 }
222
223 #[tokio::test]
224 async fn apply_add_file() {
225 let temp_dir = TempDir::new().unwrap();
226 let patch = Patch::parse("*** Begin Patch\n*** Add File: file.txt\n+content\n*** End Patch").unwrap();
227
228 let result = patch.apply(temp_dir.path()).await.unwrap();
229 assert_eq!(result, vec!["[1/1] Added file: file.txt (8 bytes)".to_string()]);
230 let written = tokio::fs::read_to_string(temp_dir.path().join("file.txt")).await.unwrap();
231 assert_eq!(written, "content\n");
232 }
233
234 #[test]
239 fn looks_like_vte_patch_detects_envelope_and_bare_headers() {
240 assert!(looks_like_vte_patch("*** Begin Patch\n*** End Patch"));
241 assert!(looks_like_vte_patch("*** Update File: src/main.rs\n@@\n+new\n"));
242 assert!(looks_like_vte_patch("*** Add File: new.txt\n+content\n"));
243 assert!(looks_like_vte_patch("*** Delete File: old.txt"));
244 assert!(looks_like_vte_patch(" \n*** Begin Patch\n*** End Patch"));
246 }
247
248 #[test]
249 fn looks_like_vte_patch_rejects_non_patch_shapes() {
250 assert!(!looks_like_vte_patch("diff --git a/f b/f\n"));
251 assert!(!looks_like_vte_patch("--- a/f\n+++ b/f\n"));
252 assert!(!looks_like_vte_patch("fn main() { println!(\"hi\"); }"));
253 assert!(!looks_like_vte_patch(""));
254 }
255
256 #[test]
257 fn looks_like_unified_diff_detects_git_diff_header() {
258 assert!(looks_like_unified_diff("diff --git a/src/main.rs b/src/main.rs\nindex abc..def 100644\n"));
259 }
260
261 #[test]
262 fn looks_like_unified_diff_detects_paired_file_headers() {
263 assert!(looks_like_unified_diff("--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1,3 +1,3 @@\n-old\n+new\n"));
264 }
265
266 #[test]
267 fn looks_like_unified_diff_rejects_vte_patch() {
268 assert!(!looks_like_unified_diff("*** Begin Patch\n*** Update File: f.rs\n@@\n-old\n+new\n*** End Patch"));
270 assert!(!looks_like_unified_diff(""));
271 assert!(!looks_like_unified_diff("fn main() {}"));
272 }
273}