Skip to main content

vtcode_core/tools/editing/patch/
mod.rs

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