Skip to main content

vtcode_core/tools/editing/patch/
mod.rs

1use 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/// Represents a single diff line inside a patch hunk.
19#[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/// Represents a chunk of changes within an update operation.
35#[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        // Format is typically: -old_start,old_count +new_start,new_count @@
87        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/// Represents a patch operation.
106#[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/// Represents a complete patch comprised of multiple operations.
123#[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// ---------------------------------------------------------------------------
163// Shape detection primitives — single source of truth for "is this a VTE
164// patch?" / "is this a unified diff?" Used by tool routing, source-field
165// selection, and parse-error guidance. Replaces the previously duplicated
166// private `looks_like_patch_text` closures in `tool_intent` and `file_ops`.
167// ---------------------------------------------------------------------------
168
169/// Cheap shape check: does this text begin with the VT Code patch envelope
170/// (`*** Begin Patch`) or a bare file-operation header (`*** Update File:`,
171/// `*** Add File:`, `*** Delete File:`)? Used by tool routing and
172/// source-field selection to distinguish a real patch from raw file contents
173/// or a unified diff. Intentionally cheap (prefix-only) — it does **not**
174/// validate the full patch.
175#[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/// Cheap shape check: does this text look like a standard unified diff
185/// (`diff --git` header, or a paired `--- `/`+++ ` file-header block)?
186/// Used to produce actionable error guidance when a model submits a
187/// git-style diff to `apply_patch` (which requires the `*** Begin Patch`
188/// envelope). VT Code patch format never uses `--- `/`+++ ` markers, so
189/// this will not false-positive on valid VTE patches.
190#[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    // Unified diffs always have a paired `--- ` / `+++ ` file-header block.
197    // Scan the first dozen lines; the pair usually appears within the first 2-3.
198    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    // -----------------------------------------------------------------------
234    // Shape-detection primitives
235    // -----------------------------------------------------------------------
236
237    #[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        // Leading whitespace is tolerated.
244        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        // A valid VTE patch must never be mistaken for a unified diff.
268        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}