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