Skip to main content

vtcode_commons/
diff_paths.rs

1use std::path::Path;
2
3/// Parse `diff --git a/... b/...` line and return normalized new path.
4pub fn parse_diff_git_path(line: &str) -> Option<String> {
5    let mut parts = line.split_whitespace();
6    if parts.next()? != "diff" || parts.next()? != "--git" {
7        return None;
8    }
9    let _old = parts.next()?;
10    let new_path = parts.next()?;
11    Some(new_path.trim_start_matches("b/").to_string())
12}
13
14/// Parse unified diff marker line (`---`/`+++`) and return normalized path.
15pub fn parse_diff_marker_path(line: &str) -> Option<String> {
16    let trimmed = line.trim_start();
17    if !(is_diff_old_file_marker_line(trimmed) || is_diff_new_file_marker_line(trimmed)) {
18        return None;
19    }
20    let path = trimmed.split_whitespace().nth(1)?;
21    if path == "/dev/null" {
22        return None;
23    }
24    Some(path.trim_start_matches("a/").trim_start_matches("b/").to_string())
25}
26
27/// Convert file path to language hint based on extension.
28pub fn language_hint_from_path(path: &str) -> Option<String> {
29    Path::new(path)
30        .extension()
31        .and_then(|ext| ext.to_str())
32        .filter(|ext| !ext.is_empty())
33        .map(|ext| ext.to_ascii_lowercase())
34}
35
36/// Whether a line is a unified diff addition content line (`+...`, excluding `+++` marker).
37pub fn is_diff_addition_line(line: &str) -> bool {
38    line.starts_with('+') && !line.starts_with("+++")
39}
40
41/// Whether a line is a unified diff removal content line (`-...`, excluding `---` marker).
42pub fn is_diff_deletion_line(line: &str) -> bool {
43    line.starts_with('-') && !line.starts_with("---")
44}
45
46/// Whether a line is a unified diff old-file marker (`--- ...`).
47pub fn is_diff_old_file_marker_line(line: &str) -> bool {
48    line.starts_with("--- ")
49}
50
51/// Whether a line is a unified diff new-file marker (`+++ ...`).
52pub fn is_diff_new_file_marker_line(line: &str) -> bool {
53    line.starts_with("+++ ")
54}
55
56/// Whether a line is an apply_patch operation header.
57pub fn is_apply_patch_header_line(line: &str) -> bool {
58    line.starts_with("*** Begin Patch")
59        || line.starts_with("*** Update File:")
60        || line.starts_with("*** Add File:")
61        || line.starts_with("*** Delete File:")
62}
63
64/// Whether a line is a recognized diff metadata/header line.
65pub fn is_diff_header_line(line: &str) -> bool {
66    line.starts_with("diff --git ")
67        || line.starts_with("@@")
68        || line.starts_with("index ")
69        || line.starts_with("new file mode ")
70        || line.starts_with("deleted file mode ")
71        || line.starts_with("rename from ")
72        || line.starts_with("rename to ")
73        || line.starts_with("copy from ")
74        || line.starts_with("copy to ")
75        || line.starts_with("similarity index ")
76        || line.starts_with("dissimilarity index ")
77        || line.starts_with("old mode ")
78        || line.starts_with("new mode ")
79        || line.starts_with("Binary files ")
80        || line.starts_with("\\ No newline at end of file")
81        || is_diff_new_file_marker_line(line)
82        || is_diff_old_file_marker_line(line)
83        || is_apply_patch_header_line(line)
84}
85
86/// Heuristic classifier for unified/git diff content.
87///
88/// This intentionally avoids classifying plain source code containing `+`/`-`
89/// lines as a diff unless there are structural diff markers.
90pub fn looks_like_diff_content(content: &str) -> bool {
91    let mut has_git_header = false;
92    let mut has_hunk = false;
93    let mut has_old_marker = false;
94    let mut has_new_marker = false;
95    let mut has_add = false;
96    let mut has_del = false;
97    let mut has_binary_or_mode_header = false;
98    let mut has_apply_patch = false;
99
100    for raw in content.lines() {
101        let line = raw.trim_start();
102        if line.is_empty() {
103            continue;
104        }
105
106        if line.starts_with("diff --git ") {
107            has_git_header = true;
108            continue;
109        }
110        if line.starts_with("@@") {
111            has_hunk = true;
112            continue;
113        }
114        if is_diff_old_file_marker_line(line) {
115            has_old_marker = true;
116            continue;
117        }
118        if is_diff_new_file_marker_line(line) {
119            has_new_marker = true;
120            continue;
121        }
122        if is_apply_patch_header_line(line) {
123            has_apply_patch = true;
124            continue;
125        }
126        if line.starts_with("new file mode ")
127            || line.starts_with("deleted file mode ")
128            || line.starts_with("rename from ")
129            || line.starts_with("rename to ")
130            || line.starts_with("copy from ")
131            || line.starts_with("copy to ")
132            || line.starts_with("similarity index ")
133            || line.starts_with("dissimilarity index ")
134            || line.starts_with("old mode ")
135            || line.starts_with("new mode ")
136            || line.starts_with("Binary files ")
137            || line.starts_with("index ")
138            || line.starts_with("\\ No newline at end of file")
139        {
140            has_binary_or_mode_header = true;
141            continue;
142        }
143
144        if is_diff_addition_line(line) {
145            has_add = true;
146            continue;
147        }
148        if is_diff_deletion_line(line) {
149            has_del = true;
150        }
151    }
152
153    if has_apply_patch {
154        return true;
155    }
156    if has_git_header && (has_hunk || has_old_marker || has_new_marker || has_binary_or_mode_header) {
157        return true;
158    }
159    if has_hunk && (has_old_marker || has_new_marker || has_add || has_del) {
160        return true;
161    }
162    if has_old_marker && has_new_marker && (has_add || has_del) {
163        return true;
164    }
165
166    false
167}
168
169/// Parse unified diff hunk header starts from `@@ -old,+new @@`.
170pub fn parse_hunk_starts(line: &str) -> Option<(usize, usize)> {
171    let trimmed = line.trim_end();
172    let rest = trimmed.strip_prefix("@@ -")?;
173    let mut parts = rest.split_whitespace();
174    let old_part = parts.next()?;
175    let new_part = parts.next()?;
176    if !new_part.starts_with('+') {
177        return None;
178    }
179
180    let old_start = old_part.split(',').next()?.parse::<usize>().ok()?;
181    let new_start = new_part.trim_start_matches('+').split(',').next()?.parse::<usize>().ok()?;
182    Some((old_start, new_start))
183}
184
185/// Normalize hunk header to start-only form: `@@ -old +new @@`.
186pub fn format_start_only_hunk_header(line: &str) -> Option<String> {
187    let (old_start, new_start) = parse_hunk_starts(line)?;
188    Some(format!("@@ -{old_start} +{new_start} @@"))
189}
190
191#[cfg(test)]
192mod tests {
193    use super::{
194        format_start_only_hunk_header, is_apply_patch_header_line, is_diff_addition_line, is_diff_deletion_line,
195        is_diff_header_line, is_diff_new_file_marker_line, is_diff_old_file_marker_line, language_hint_from_path,
196        looks_like_diff_content, parse_diff_git_path, parse_diff_marker_path, parse_hunk_starts,
197    };
198
199    #[test]
200    fn parses_git_diff_path() {
201        let line = "diff --git a/src/lib.rs b/src/lib.rs";
202        assert_eq!(parse_diff_git_path(line).as_deref(), Some("src/lib.rs"));
203    }
204
205    #[test]
206    fn parses_marker_path() {
207        assert_eq!(parse_diff_marker_path("+++ b/src/main.rs").as_deref(), Some("src/main.rs"));
208        assert_eq!(parse_diff_marker_path("--- /dev/null"), None);
209    }
210
211    #[test]
212    fn infers_language_hint_from_extension() {
213        assert_eq!(language_hint_from_path("src/main.RS").as_deref(), Some("rs"));
214        assert_eq!(language_hint_from_path("Makefile"), None);
215    }
216
217    #[test]
218    fn parses_hunk_starts() {
219        assert_eq!(parse_hunk_starts("@@ -536,4 +540,5 @@"), Some((536, 540)));
220        assert_eq!(parse_hunk_starts("not a hunk"), None);
221    }
222
223    #[test]
224    fn formats_start_only_hunk_header() {
225        assert_eq!(format_start_only_hunk_header("@@ -536,4 +540,5 @@"), Some("@@ -536 +540 @@".to_string()));
226    }
227
228    #[test]
229    fn detects_diff_add_remove_lines() {
230        assert!(is_diff_addition_line("+added"));
231        assert!(!is_diff_addition_line("+++ b/file.rs"));
232        assert!(is_diff_deletion_line("-removed"));
233        assert!(!is_diff_deletion_line("--- a/file.rs"));
234    }
235
236    #[test]
237    fn detects_diff_header_lines() {
238        assert!(is_diff_header_line("diff --git a/a b/a"));
239        assert!(is_diff_header_line("@@ -1 +1 @@"));
240        assert!(is_diff_header_line("+++ b/src/main.rs"));
241        assert!(!is_diff_header_line("println!(\"diff --git\");"));
242    }
243
244    #[test]
245    fn detects_marker_and_apply_patch_header_lines() {
246        assert!(is_diff_old_file_marker_line("--- a/src/lib.rs"));
247        assert!(is_diff_new_file_marker_line("+++ b/src/lib.rs"));
248        assert!(is_apply_patch_header_line("*** Update File: src/lib.rs"));
249        assert!(!is_apply_patch_header_line("*** End Patch"));
250    }
251
252    #[test]
253    fn classifies_git_diff_content() {
254        let diff = "diff --git a/a.rs b/a.rs\n@@ -1 +1 @@\n-old\n+new\n";
255        assert!(looks_like_diff_content(diff));
256    }
257
258    #[test]
259    fn classifies_apply_patch_content() {
260        let patch = "*** Begin Patch\n*** Update File: a.rs\n@@\n-old\n+new\n*** End Patch\n";
261        assert!(looks_like_diff_content(patch));
262    }
263
264    #[test]
265    fn avoids_false_positive_for_regular_code() {
266        let code = "fn delta(x: i32) -> i32 {\n    let y = x + 1;\n    let z = x - 1;\n    y + z\n}\n";
267        assert!(!looks_like_diff_content(code));
268    }
269
270    #[test]
271    fn avoids_false_positive_for_plus_minus_logs() {
272        let log = "+ started service\n- previous pid cleaned\n";
273        assert!(!looks_like_diff_content(log));
274    }
275}