1use std::borrow::Cow;
2use std::path::{Path, PathBuf};
3
4use rustc_hash::{FxHashMap, FxHashSet};
5
6pub const MAX_DIFF_BYTES: u64 = 10 * 1024 * 1024;
8
9pub const MAX_ADDED_LINES: usize = 1_000_000;
11
12#[derive(Debug, Default, Clone)]
21pub struct DiffIndex {
22 added_lines: FxHashMap<String, FxHashSet<u64>>,
23 changed_paths: FxHashSet<String>,
24 touched_files: FxHashSet<String>,
25 added_line_count: usize,
26 total_added_lines: usize,
27 total_removed_lines: usize,
28 hunk_count: usize,
29 rename_pairs: FxHashMap<String, String>,
30 base: Option<PathBuf>,
31 root_offset: String,
32}
33
34#[derive(Default)]
36struct DiffParseState {
37 current_file: Option<String>,
38 new_line: u64,
39 pending_old_path: Option<String>,
40 pending_rename_from: Option<String>,
41}
42
43impl DiffIndex {
44 #[must_use]
49 pub fn from_unified_diff(diff: &str) -> Self {
50 let mut index = Self::default();
51 let mut state = DiffParseState::default();
52
53 for line in diff.lines() {
54 if index.handle_diff_header_line(line, &mut state) {
55 continue;
56 }
57 index.handle_diff_content_line(line, &mut state);
58 }
59
60 index
61 }
62
63 fn handle_diff_header_line(&mut self, line: &str, state: &mut DiffParseState) -> bool {
64 if line.starts_with("diff --git ") {
65 state.current_file = None;
66 state.pending_old_path = None;
67 state.pending_rename_from = None;
68 return true;
69 }
70 if let Some(rest) = line.strip_prefix("rename from ") {
71 state.pending_rename_from = Some(rest.to_owned());
72 return true;
73 }
74 if let Some(rest) = line.strip_prefix("rename to ") {
75 if let Some(from) = state.pending_rename_from.take() {
76 self.rename_pairs.insert(rest.to_owned(), from);
77 self.changed_paths.insert(rest.to_owned());
78 self.touched_files.insert(rest.to_owned());
79 }
80 return true;
81 }
82 if let Some(path) = line.strip_prefix("--- a/") {
83 state.pending_old_path = Some(path.to_string());
84 return true;
85 }
86 if line.starts_with("--- /dev/null") {
87 state.pending_old_path = None;
88 return true;
89 }
90 if let Some(path) = line.strip_prefix("+++ b/") {
91 state.pending_old_path = None;
92 state.current_file = Some(path.to_string());
93 self.changed_paths.insert(path.to_string());
94 self.touched_files.insert(path.to_string());
95 return true;
96 }
97 if line.starts_with("+++ /dev/null") {
98 state.current_file = state.pending_old_path.take();
99 if let Some(path) = state.current_file.as_ref() {
100 self.changed_paths.insert(path.clone());
101 }
102 return true;
103 }
104 if let Some(header) = line.strip_prefix("@@ ") {
105 if let Some(start) = parse_new_hunk_start(header) {
106 state.new_line = start;
107 self.hunk_count += 1;
108 }
109 return true;
110 }
111 false
112 }
113
114 fn handle_diff_content_line(&mut self, line: &str, state: &mut DiffParseState) {
115 let Some(path) = state.current_file.as_ref() else {
116 return;
117 };
118 if line.starts_with('+') {
119 self.total_added_lines += 1;
120 if !line.starts_with("+++") && self.added_line_count < MAX_ADDED_LINES {
121 self.added_lines
122 .entry(path.clone())
123 .or_default()
124 .insert(state.new_line);
125 self.added_line_count += 1;
126 }
127 state.new_line += 1;
128 } else if line.starts_with('-') {
129 self.total_removed_lines += 1;
130 } else {
131 state.new_line += 1;
132 }
133 }
134
135 #[must_use]
138 pub fn old_path_for(&self, head_path: &str) -> Option<&str> {
139 self.rename_pairs.get(head_path).map(String::as_str)
140 }
141
142 #[must_use]
144 pub fn added_line_count(&self) -> usize {
145 self.added_line_count
146 }
147
148 #[must_use]
150 pub fn hunk_count(&self) -> usize {
151 self.hunk_count
152 }
153
154 #[must_use]
157 pub fn net_lines(&self) -> i64 {
158 let added = i64::try_from(self.total_added_lines).unwrap_or(i64::MAX);
159 let removed = i64::try_from(self.total_removed_lines).unwrap_or(i64::MAX);
160 added.saturating_sub(removed)
161 }
162
163 #[must_use]
165 pub fn changes_path(&self, path: &str) -> bool {
166 self.changed_paths.contains(path)
167 }
168
169 pub fn changed_paths(&self) -> impl Iterator<Item = &str> {
171 self.changed_paths.iter().map(String::as_str)
172 }
173
174 #[must_use]
176 pub fn touches_file(&self, path: &str) -> bool {
177 self.touched_files.contains(path)
178 }
179
180 #[must_use]
183 pub fn range_overlaps_added(&self, path: &str, start: u64, end: u64) -> bool {
184 if end < start {
185 return false;
186 }
187 let Some(added) = self.added_lines.get(path) else {
188 return false;
189 };
190 let lo = start.max(1);
191 added.iter().any(|&line| line >= lo && line <= end)
192 }
193
194 #[must_use]
196 pub fn line_is_added(&self, path: &str, line: u64) -> bool {
197 self.added_lines
198 .get(path)
199 .is_some_and(|lines| lines.contains(&line))
200 }
201
202 #[must_use]
204 pub fn line_within_added_context(&self, path: &str, line: u64, radius: u64) -> bool {
205 self.added_lines
206 .get(path)
207 .is_some_and(|lines| lines.iter().any(|added| line.abs_diff(*added) <= radius))
208 }
209
210 #[must_use]
212 pub fn added_lines_in(&self, path: &str) -> Option<&FxHashSet<u64>> {
213 self.added_lines.get(path)
214 }
215
216 #[must_use]
219 pub fn with_base(mut self, base: impl Into<PathBuf>) -> Self {
220 self.base = Some(base.into());
221 self
222 }
223
224 #[must_use]
232 pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
233 let mut offset = offset.into();
234 offset.truncate(offset.trim_end_matches('/').len());
237 self.root_offset = offset;
238 self
239 }
240
241 #[must_use]
244 pub fn root_offset(&self) -> &str {
245 &self.root_offset
246 }
247
248 #[must_use]
250 pub fn key_for_root_relative<'a>(&self, rel: &'a str) -> Cow<'a, str> {
251 if self.root_offset.is_empty() {
252 return Cow::Borrowed(rel);
253 }
254 Cow::Owned(format!("{}/{rel}", self.root_offset))
255 }
256
257 #[must_use]
260 pub fn root_relative_from_key<'a>(&self, key: &'a str) -> Option<Cow<'a, str>> {
261 if self.root_offset.is_empty() {
262 return Some(Cow::Borrowed(key));
263 }
264 strip_path_component_prefix(key, &self.root_offset).map(Cow::Borrowed)
265 }
266
267 #[must_use]
271 pub fn old_path_for_root_relative<'a>(&'a self, rel: &str) -> Option<Cow<'a, str>> {
272 let old = self.old_path_for(&self.key_for_root_relative(rel))?;
273 self.root_relative_from_key(old)
274 }
275
276 #[must_use]
279 pub fn base(&self) -> Option<&Path> {
280 self.base.as_deref()
281 }
282
283 pub fn touched_files(&self) -> impl Iterator<Item = &str> {
286 self.touched_files.iter().map(String::as_str)
287 }
288
289 #[must_use]
295 pub fn key_for(&self, path: &Path, fallback_root: &Path) -> Option<String> {
296 relative_to_diff_path(path, self.base.as_deref().unwrap_or(fallback_root))
297 }
298}
299
300#[must_use]
303pub fn relative_to_diff_path(path: &Path, root: &Path) -> Option<String> {
304 if let Ok(stripped) = path.strip_prefix(root) {
305 return Some(stripped.to_string_lossy().replace('\\', "/"));
306 }
307 if fallow_types::path_util::is_absolute_path_any_platform(path) {
308 return None;
309 }
310 Some(path.to_string_lossy().replace('\\', "/"))
311}
312
313#[must_use]
317pub fn strip_path_component_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
318 path.strip_prefix(prefix)?.strip_prefix('/')
319}
320
321pub fn parse_new_hunk_start(header: &str) -> Option<u64> {
323 let plus = header.find('+')?;
324 let rest = &header[plus + 1..];
325 let end = rest
326 .find(|c: char| c == ',' || c.is_ascii_whitespace())
327 .unwrap_or(rest.len());
328 rest[..end].parse().ok()
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn from_unified_diff_caps_added_lines_at_threshold() {
337 let header =
338 "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,100 @@\n";
339 let mut body = String::with_capacity(MAX_ADDED_LINES * 16);
340 for _ in 0..(MAX_ADDED_LINES + 100) {
341 body.push_str("+x\n");
342 }
343 let mut diff = String::with_capacity(header.len() + body.len());
344 diff.push_str(header);
345 diff.push_str(&body);
346
347 let index = DiffIndex::from_unified_diff(&diff);
348 assert!(
349 index.added_line_count() <= MAX_ADDED_LINES,
350 "indexed {} lines, cap is {MAX_ADDED_LINES}",
351 index.added_line_count()
352 );
353 assert_eq!(index.net_lines(), (MAX_ADDED_LINES + 100) as i64);
354 assert_eq!(index.hunk_count(), 1);
355 }
356
357 #[test]
358 fn from_unified_diff_counts_additions_removals_and_hunks() {
359 let diff = "\
360diff --git a/src/a.ts b/src/a.ts
361--- a/src/a.ts
362+++ b/src/a.ts
363@@ -1,3 +1,4 @@
364-old
365+new
366+extra
367+++flag
368 context
369@@ -10,2 +11,1 @@
370-removed
371---flag
372 kept
373";
374 let index = DiffIndex::from_unified_diff(diff);
375
376 assert_eq!(index.hunk_count(), 2);
377 assert_eq!(index.net_lines(), 0);
378 assert!(index.changes_path("src/a.ts"));
379 assert!(index.touches_file("src/a.ts"));
380 }
381
382 #[test]
383 fn rename_only_diff_has_no_line_or_hunk_changes() {
384 let diff = "\
385diff --git a/src/old.ts b/src/new.ts
386similarity index 100%
387rename from src/old.ts
388rename to src/new.ts
389";
390 let index = DiffIndex::from_unified_diff(diff);
391
392 assert_eq!(index.hunk_count(), 0);
393 assert_eq!(index.net_lines(), 0);
394 assert!(index.changes_path("src/new.ts"));
395 assert!(index.touches_file("src/new.ts"));
396 }
397
398 #[test]
399 fn range_overlaps_added_hotspot_starting_before_diff_touches_inside() {
400 let diff = "\
401diff --git a/src/big.ts b/src/big.ts
402--- a/src/big.ts
403+++ b/src/big.ts
404@@ -114,1 +114,2 @@
405 ctx
406+touched
407";
408 let index = DiffIndex::from_unified_diff(diff);
409 assert!(index.range_overlaps_added("src/big.ts", 10, 120));
410 assert!(!index.range_overlaps_added("src/other.ts", 10, 120));
411 assert!(!index.range_overlaps_added("src/big.ts", 10, 100));
412 assert!(!index.range_overlaps_added("src/big.ts", 200, 100));
413 }
414
415 #[test]
416 fn rename_header_records_old_path() {
417 let diff = "\
418diff --git a/src/old.ts b/src/new.ts
419similarity index 90%
420rename from src/old.ts
421rename to src/new.ts
422--- a/src/old.ts
423+++ b/src/new.ts
424@@ -1,1 +1,1 @@
425-old
426+new
427";
428 let index = DiffIndex::from_unified_diff(diff);
429 assert_eq!(index.old_path_for("src/new.ts"), Some("src/old.ts"));
430 assert!(index.touches_file("src/new.ts"));
431 }
432
433 #[test]
434 fn empty_diff_has_zero_added_lines_and_no_touched_files() {
435 let index = DiffIndex::from_unified_diff("");
436 assert_eq!(index.added_line_count(), 0);
437 assert!(!index.touches_file("src/a.ts"));
438 }
439
440 #[test]
441 fn delete_only_diff_records_removal_without_touching_head_file() {
442 let diff = "\
443diff --git a/src/a.ts b/src/a.ts
444--- a/src/a.ts
445+++ /dev/null
446@@ -1,1 +0,0 @@
447-old
448";
449 let index = DiffIndex::from_unified_diff(diff);
450 assert_eq!(index.added_line_count(), 0);
451 assert_eq!(index.hunk_count(), 1);
452 assert_eq!(index.net_lines(), -1);
453 assert!(index.changes_path("src/a.ts"));
454 assert!(!index.touches_file("src/a.ts"));
455 }
456
457 #[test]
458 fn relative_to_diff_path_strips_absolute_root() {
459 let root = Path::new("/project");
460 let path = Path::new("/project/src/a.ts");
461 assert_eq!(
462 relative_to_diff_path(path, root).as_deref(),
463 Some("src/a.ts")
464 );
465 }
466
467 #[test]
468 fn relative_to_diff_path_passes_through_relative() {
469 let root = Path::new("/project");
470 let path = Path::new("src/a.ts");
471 assert_eq!(
472 relative_to_diff_path(path, root).as_deref(),
473 Some("src/a.ts")
474 );
475 }
476
477 #[test]
478 fn relative_to_diff_path_returns_none_for_path_outside_root() {
479 let root = Path::new("/project");
480 let path = Path::new("/elsewhere/src/a.ts");
481 assert!(relative_to_diff_path(path, root).is_none());
482 }
483
484 #[test]
485 fn key_for_without_base_relativizes_against_the_fallback_root() {
486 let index = DiffIndex::default();
487 assert_eq!(
488 index
489 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
490 .as_deref(),
491 Some("src/a.ts")
492 );
493 }
494
495 #[test]
496 fn key_for_with_base_equal_to_root_is_unchanged() {
497 let index = DiffIndex::default().with_base("/repo");
498 assert_eq!(
499 index
500 .key_for(Path::new("/repo/src/a.ts"), Path::new("/repo"))
501 .as_deref(),
502 Some("src/a.ts")
503 );
504 }
505
506 #[test]
509 fn key_for_with_base_above_root_yields_repo_root_relative_key() {
510 let index = DiffIndex::default().with_base("/repo");
511 assert_eq!(
512 index
513 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
514 .as_deref(),
515 Some("pkg/src/a.ts")
516 );
517 }
518
519 #[test]
520 fn key_for_with_base_above_root_matches_a_repo_root_relative_diff() {
521 let diff = "\
522diff --git a/pkg/src/a.ts b/pkg/src/a.ts
523--- a/pkg/src/a.ts
524+++ b/pkg/src/a.ts
525@@ -1,0 +2,1 @@
526+added
527";
528 let index = DiffIndex::from_unified_diff(diff).with_base("/repo");
529 let key = index
530 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
531 .expect("finding path is under the base");
532
533 assert!(index.touches_file(&key));
534 assert!(index.line_is_added(&key, 2));
535
536 let unbased = DiffIndex::from_unified_diff(diff);
538 let missed = unbased
539 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
540 .expect("still relativizable");
541 assert_eq!(missed, "src/a.ts");
542 assert!(!unbased.touches_file(&missed));
543 }
544
545 #[test]
546 fn key_for_returns_none_for_path_outside_the_base() {
547 let index = DiffIndex::default().with_base("/repo");
548 assert!(
549 index
550 .key_for(Path::new("/elsewhere/a.ts"), Path::new("/repo/pkg"))
551 .is_none()
552 );
553 }
554
555 #[test]
556 fn old_path_for_root_relative_crosses_the_namespace_and_back() {
557 let diff = "\
558diff --git a/pkg/src/old.ts b/pkg/src/new.ts
559similarity index 90%
560rename from pkg/src/old.ts
561rename to pkg/src/new.ts
562--- a/pkg/src/old.ts
563+++ b/pkg/src/new.ts
564@@ -1,1 +1,1 @@
565-old
566+new
567";
568 let index = DiffIndex::from_unified_diff(diff)
569 .with_base("/repo")
570 .with_root_offset("pkg");
571
572 assert_eq!(
574 index.old_path_for_root_relative("src/new.ts").as_deref(),
575 Some("src/old.ts")
576 );
577 assert_eq!(index.old_path_for("pkg/src/new.ts"), Some("pkg/src/old.ts"));
579 assert_eq!(index.old_path_for_root_relative("src/absent.ts"), None);
580 }
581
582 #[test]
583 fn root_relative_key_round_trips() {
584 let index = DiffIndex::default().with_root_offset("packages/pkg");
585 assert_eq!(
586 index.key_for_root_relative("src/a.ts"),
587 "packages/pkg/src/a.ts"
588 );
589 assert_eq!(
590 index
591 .root_relative_from_key("packages/pkg/src/a.ts")
592 .as_deref(),
593 Some("src/a.ts")
594 );
595 assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
597 assert_eq!(
599 index.root_relative_from_key("packages/pkg-extra/a.ts"),
600 None
601 );
602 }
603
604 #[test]
605 fn empty_root_offset_is_identity() {
606 let index = DiffIndex::default();
607 assert_eq!(index.key_for_root_relative("src/a.ts"), "src/a.ts");
608 assert_eq!(
609 index.root_relative_from_key("src/a.ts").as_deref(),
610 Some("src/a.ts")
611 );
612 }
613
614 #[test]
615 fn touched_files_enumerates_diff_header_paths() {
616 let diff = "\
617diff --git a/pkg/a.ts b/pkg/a.ts
618--- a/pkg/a.ts
619+++ b/pkg/a.ts
620@@ -0,0 +1,1 @@
621+x
622";
623 let index = DiffIndex::from_unified_diff(diff);
624 assert_eq!(index.touched_files().collect::<Vec<_>>(), vec!["pkg/a.ts"]);
625 }
626}