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    {
158        return true;
159    }
160    if has_hunk && (has_old_marker || has_new_marker || has_add || has_del) {
161        return true;
162    }
163    if has_old_marker && has_new_marker && (has_add || has_del) {
164        return true;
165    }
166
167    false
168}
169
170/// Parse unified diff hunk header starts from `@@ -old,+new @@`.
171pub fn parse_hunk_starts(line: &str) -> Option<(usize, usize)> {
172    let trimmed = line.trim_end();
173    let rest = trimmed.strip_prefix("@@ -")?;
174    let mut parts = rest.split_whitespace();
175    let old_part = parts.next()?;
176    let new_part = parts.next()?;
177    if !new_part.starts_with('+') {
178        return None;
179    }
180
181    let old_start = old_part.split(',').next()?.parse::<usize>().ok()?;
182    let new_start = new_part.trim_start_matches('+').split(',').next()?.parse::<usize>().ok()?;
183    Some((old_start, new_start))
184}
185
186/// Normalize hunk header to start-only form: `@@ -old +new @@`.
187pub fn format_start_only_hunk_header(line: &str) -> Option<String> {
188    let (old_start, new_start) = parse_hunk_starts(line)?;
189    Some(format!("@@ -{old_start} +{new_start} @@"))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::{
195        format_start_only_hunk_header, is_apply_patch_header_line, is_diff_addition_line,
196        is_diff_deletion_line, is_diff_header_line, is_diff_new_file_marker_line,
197        is_diff_old_file_marker_line, language_hint_from_path, looks_like_diff_content,
198        parse_diff_git_path, parse_diff_marker_path, parse_hunk_starts,
199    };
200
201    #[test]
202    fn parses_git_diff_path() {
203        let line = "diff --git a/src/lib.rs b/src/lib.rs";
204        assert_eq!(parse_diff_git_path(line).as_deref(), Some("src/lib.rs"));
205    }
206
207    #[test]
208    fn parses_marker_path() {
209        assert_eq!(parse_diff_marker_path("+++ b/src/main.rs").as_deref(), Some("src/main.rs"));
210        assert_eq!(parse_diff_marker_path("--- /dev/null"), None);
211    }
212
213    #[test]
214    fn infers_language_hint_from_extension() {
215        assert_eq!(language_hint_from_path("src/main.RS").as_deref(), Some("rs"));
216        assert_eq!(language_hint_from_path("Makefile"), None);
217    }
218
219    #[test]
220    fn parses_hunk_starts() {
221        assert_eq!(parse_hunk_starts("@@ -536,4 +540,5 @@"), Some((536, 540)));
222        assert_eq!(parse_hunk_starts("not a hunk"), None);
223    }
224
225    #[test]
226    fn formats_start_only_hunk_header() {
227        assert_eq!(
228            format_start_only_hunk_header("@@ -536,4 +540,5 @@"),
229            Some("@@ -536 +540 @@".to_string())
230        );
231    }
232
233    #[test]
234    fn detects_diff_add_remove_lines() {
235        assert!(is_diff_addition_line("+added"));
236        assert!(!is_diff_addition_line("+++ b/file.rs"));
237        assert!(is_diff_deletion_line("-removed"));
238        assert!(!is_diff_deletion_line("--- a/file.rs"));
239    }
240
241    #[test]
242    fn detects_diff_header_lines() {
243        assert!(is_diff_header_line("diff --git a/a b/a"));
244        assert!(is_diff_header_line("@@ -1 +1 @@"));
245        assert!(is_diff_header_line("+++ b/src/main.rs"));
246        assert!(!is_diff_header_line("println!(\"diff --git\");"));
247    }
248
249    #[test]
250    fn detects_marker_and_apply_patch_header_lines() {
251        assert!(is_diff_old_file_marker_line("--- a/src/lib.rs"));
252        assert!(is_diff_new_file_marker_line("+++ b/src/lib.rs"));
253        assert!(is_apply_patch_header_line("*** Update File: src/lib.rs"));
254        assert!(!is_apply_patch_header_line("*** End Patch"));
255    }
256
257    #[test]
258    fn classifies_git_diff_content() {
259        let diff = "diff --git a/a.rs b/a.rs\n@@ -1 +1 @@\n-old\n+new\n";
260        assert!(looks_like_diff_content(diff));
261    }
262
263    #[test]
264    fn classifies_apply_patch_content() {
265        let patch = "*** Begin Patch\n*** Update File: a.rs\n@@\n-old\n+new\n*** End Patch\n";
266        assert!(looks_like_diff_content(patch));
267    }
268
269    #[test]
270    fn avoids_false_positive_for_regular_code() {
271        let code =
272            "fn delta(x: i32) -> i32 {\n    let y = x + 1;\n    let z = x - 1;\n    y + z\n}\n";
273        assert!(!looks_like_diff_content(code));
274    }
275
276    #[test]
277    fn avoids_false_positive_for_plus_minus_logs() {
278        let log = "+ started service\n- previous pid cleaned\n";
279        assert!(!looks_like_diff_content(log));
280    }
281}