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