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 self.first_added_line_in_range(path, start, end).is_some()
185 }
186
187 #[must_use]
190 pub fn first_added_line_in_range(&self, path: &str, start: u64, end: u64) -> Option<u64> {
191 if end < start {
192 return None;
193 }
194 let added = self.added_lines.get(path)?;
195 let lo = start.max(1);
196 added
197 .iter()
198 .copied()
199 .filter(|line| *line >= lo && *line <= end)
200 .min()
201 }
202
203 #[must_use]
205 pub fn line_is_added(&self, path: &str, line: u64) -> bool {
206 self.added_lines
207 .get(path)
208 .is_some_and(|lines| lines.contains(&line))
209 }
210
211 #[must_use]
213 pub fn line_within_added_context(&self, path: &str, line: u64, radius: u64) -> bool {
214 self.added_lines
215 .get(path)
216 .is_some_and(|lines| lines.iter().any(|added| line.abs_diff(*added) <= radius))
217 }
218
219 #[must_use]
221 pub fn added_lines_in(&self, path: &str) -> Option<&FxHashSet<u64>> {
222 self.added_lines.get(path)
223 }
224
225 #[must_use]
228 pub fn with_base(mut self, base: impl Into<PathBuf>) -> Self {
229 self.base = Some(base.into());
230 self
231 }
232
233 #[must_use]
241 pub fn with_root_offset(mut self, offset: impl Into<String>) -> Self {
242 let mut offset = offset.into();
243 offset.truncate(offset.trim_end_matches('/').len());
246 self.root_offset = offset;
247 self
248 }
249
250 #[must_use]
253 pub fn root_offset(&self) -> &str {
254 &self.root_offset
255 }
256
257 #[must_use]
259 pub fn key_for_root_relative<'a>(&self, rel: &'a str) -> Cow<'a, str> {
260 if self.root_offset.is_empty() {
261 return Cow::Borrowed(rel);
262 }
263 Cow::Owned(format!("{}/{rel}", self.root_offset))
264 }
265
266 #[must_use]
269 pub fn root_relative_from_key<'a>(&self, key: &'a str) -> Option<Cow<'a, str>> {
270 if self.root_offset.is_empty() {
271 return Some(Cow::Borrowed(key));
272 }
273 strip_path_component_prefix(key, &self.root_offset).map(Cow::Borrowed)
274 }
275
276 #[must_use]
280 pub fn old_path_for_root_relative<'a>(&'a self, rel: &str) -> Option<Cow<'a, str>> {
281 let old = self.old_path_for(&self.key_for_root_relative(rel))?;
282 self.root_relative_from_key(old)
283 }
284
285 #[must_use]
288 pub fn base(&self) -> Option<&Path> {
289 self.base.as_deref()
290 }
291
292 pub fn touched_files(&self) -> impl Iterator<Item = &str> {
295 self.touched_files.iter().map(String::as_str)
296 }
297
298 #[must_use]
304 pub fn key_for(&self, path: &Path, fallback_root: &Path) -> Option<String> {
305 relative_to_diff_path(path, self.base.as_deref().unwrap_or(fallback_root))
306 }
307}
308
309#[must_use]
312pub fn relative_to_diff_path(path: &Path, root: &Path) -> Option<String> {
313 if let Ok(stripped) = path.strip_prefix(root) {
314 return Some(stripped.to_string_lossy().replace('\\', "/"));
315 }
316 if fallow_types::path_util::is_absolute_path_any_platform(path) {
317 return None;
318 }
319 Some(path.to_string_lossy().replace('\\', "/"))
320}
321
322#[must_use]
326pub fn strip_path_component_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
327 path.strip_prefix(prefix)?.strip_prefix('/')
328}
329
330pub fn parse_new_hunk_start(header: &str) -> Option<u64> {
332 let plus = header.find('+')?;
333 let rest = &header[plus + 1..];
334 let end = rest
335 .find(|c: char| c == ',' || c.is_ascii_whitespace())
336 .unwrap_or(rest.len());
337 rest[..end].parse().ok()
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn from_unified_diff_caps_added_lines_at_threshold() {
346 let header =
347 "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -0,0 +1,100 @@\n";
348 let mut body = String::with_capacity(MAX_ADDED_LINES * 16);
349 for _ in 0..(MAX_ADDED_LINES + 100) {
350 body.push_str("+x\n");
351 }
352 let mut diff = String::with_capacity(header.len() + body.len());
353 diff.push_str(header);
354 diff.push_str(&body);
355
356 let index = DiffIndex::from_unified_diff(&diff);
357 assert!(
358 index.added_line_count() <= MAX_ADDED_LINES,
359 "indexed {} lines, cap is {MAX_ADDED_LINES}",
360 index.added_line_count()
361 );
362 assert_eq!(index.net_lines(), (MAX_ADDED_LINES + 100) as i64);
363 assert_eq!(index.hunk_count(), 1);
364 }
365
366 #[test]
367 fn from_unified_diff_counts_additions_removals_and_hunks() {
368 let diff = "\
369diff --git a/src/a.ts b/src/a.ts
370--- a/src/a.ts
371+++ b/src/a.ts
372@@ -1,3 +1,4 @@
373-old
374+new
375+extra
376+++flag
377 context
378@@ -10,2 +11,1 @@
379-removed
380---flag
381 kept
382";
383 let index = DiffIndex::from_unified_diff(diff);
384
385 assert_eq!(index.hunk_count(), 2);
386 assert_eq!(index.net_lines(), 0);
387 assert!(index.changes_path("src/a.ts"));
388 assert!(index.touches_file("src/a.ts"));
389 }
390
391 #[test]
392 fn rename_only_diff_has_no_line_or_hunk_changes() {
393 let diff = "\
394diff --git a/src/old.ts b/src/new.ts
395similarity index 100%
396rename from src/old.ts
397rename to src/new.ts
398";
399 let index = DiffIndex::from_unified_diff(diff);
400
401 assert_eq!(index.hunk_count(), 0);
402 assert_eq!(index.net_lines(), 0);
403 assert!(index.changes_path("src/new.ts"));
404 assert!(index.touches_file("src/new.ts"));
405 }
406
407 #[test]
408 fn range_overlaps_added_hotspot_starting_before_diff_touches_inside() {
409 let diff = "\
410diff --git a/src/big.ts b/src/big.ts
411--- a/src/big.ts
412+++ b/src/big.ts
413@@ -114,1 +114,2 @@
414 ctx
415+touched
416";
417 let index = DiffIndex::from_unified_diff(diff);
418 assert!(index.range_overlaps_added("src/big.ts", 10, 120));
419 assert!(!index.range_overlaps_added("src/other.ts", 10, 120));
420 assert!(!index.range_overlaps_added("src/big.ts", 10, 100));
421 assert!(!index.range_overlaps_added("src/big.ts", 200, 100));
422 }
423
424 #[test]
425 fn rename_header_records_old_path() {
426 let diff = "\
427diff --git a/src/old.ts b/src/new.ts
428similarity index 90%
429rename from src/old.ts
430rename to src/new.ts
431--- a/src/old.ts
432+++ b/src/new.ts
433@@ -1,1 +1,1 @@
434-old
435+new
436";
437 let index = DiffIndex::from_unified_diff(diff);
438 assert_eq!(index.old_path_for("src/new.ts"), Some("src/old.ts"));
439 assert!(index.touches_file("src/new.ts"));
440 }
441
442 #[test]
443 fn empty_diff_has_zero_added_lines_and_no_touched_files() {
444 let index = DiffIndex::from_unified_diff("");
445 assert_eq!(index.added_line_count(), 0);
446 assert!(!index.touches_file("src/a.ts"));
447 }
448
449 #[test]
450 fn delete_only_diff_records_removal_without_touching_head_file() {
451 let diff = "\
452diff --git a/src/a.ts b/src/a.ts
453--- a/src/a.ts
454+++ /dev/null
455@@ -1,1 +0,0 @@
456-old
457";
458 let index = DiffIndex::from_unified_diff(diff);
459 assert_eq!(index.added_line_count(), 0);
460 assert_eq!(index.hunk_count(), 1);
461 assert_eq!(index.net_lines(), -1);
462 assert!(index.changes_path("src/a.ts"));
463 assert!(!index.touches_file("src/a.ts"));
464 }
465
466 #[test]
467 fn relative_to_diff_path_strips_absolute_root() {
468 let root = Path::new("/project");
469 let path = Path::new("/project/src/a.ts");
470 assert_eq!(
471 relative_to_diff_path(path, root).as_deref(),
472 Some("src/a.ts")
473 );
474 }
475
476 #[test]
477 fn relative_to_diff_path_passes_through_relative() {
478 let root = Path::new("/project");
479 let path = Path::new("src/a.ts");
480 assert_eq!(
481 relative_to_diff_path(path, root).as_deref(),
482 Some("src/a.ts")
483 );
484 }
485
486 #[test]
487 fn relative_to_diff_path_returns_none_for_path_outside_root() {
488 let root = Path::new("/project");
489 let path = Path::new("/elsewhere/src/a.ts");
490 assert!(relative_to_diff_path(path, root).is_none());
491 }
492
493 #[test]
494 fn key_for_without_base_relativizes_against_the_fallback_root() {
495 let index = DiffIndex::default();
496 assert_eq!(
497 index
498 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
499 .as_deref(),
500 Some("src/a.ts")
501 );
502 }
503
504 #[test]
505 fn key_for_with_base_equal_to_root_is_unchanged() {
506 let index = DiffIndex::default().with_base("/repo");
507 assert_eq!(
508 index
509 .key_for(Path::new("/repo/src/a.ts"), Path::new("/repo"))
510 .as_deref(),
511 Some("src/a.ts")
512 );
513 }
514
515 #[test]
518 fn key_for_with_base_above_root_yields_repo_root_relative_key() {
519 let index = DiffIndex::default().with_base("/repo");
520 assert_eq!(
521 index
522 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
523 .as_deref(),
524 Some("pkg/src/a.ts")
525 );
526 }
527
528 #[test]
529 fn key_for_with_base_above_root_matches_a_repo_root_relative_diff() {
530 let diff = "\
531diff --git a/pkg/src/a.ts b/pkg/src/a.ts
532--- a/pkg/src/a.ts
533+++ b/pkg/src/a.ts
534@@ -1,0 +2,1 @@
535+added
536";
537 let index = DiffIndex::from_unified_diff(diff).with_base("/repo");
538 let key = index
539 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
540 .expect("finding path is under the base");
541
542 assert!(index.touches_file(&key));
543 assert!(index.line_is_added(&key, 2));
544
545 let unbased = DiffIndex::from_unified_diff(diff);
547 let missed = unbased
548 .key_for(Path::new("/repo/pkg/src/a.ts"), Path::new("/repo/pkg"))
549 .expect("still relativizable");
550 assert_eq!(missed, "src/a.ts");
551 assert!(!unbased.touches_file(&missed));
552 }
553
554 #[test]
555 fn key_for_returns_none_for_path_outside_the_base() {
556 let index = DiffIndex::default().with_base("/repo");
557 assert!(
558 index
559 .key_for(Path::new("/elsewhere/a.ts"), Path::new("/repo/pkg"))
560 .is_none()
561 );
562 }
563
564 #[test]
565 fn old_path_for_root_relative_crosses_the_namespace_and_back() {
566 let diff = "\
567diff --git a/pkg/src/old.ts b/pkg/src/new.ts
568similarity index 90%
569rename from pkg/src/old.ts
570rename to pkg/src/new.ts
571--- a/pkg/src/old.ts
572+++ b/pkg/src/new.ts
573@@ -1,1 +1,1 @@
574-old
575+new
576";
577 let index = DiffIndex::from_unified_diff(diff)
578 .with_base("/repo")
579 .with_root_offset("pkg");
580
581 assert_eq!(
583 index.old_path_for_root_relative("src/new.ts").as_deref(),
584 Some("src/old.ts")
585 );
586 assert_eq!(index.old_path_for("pkg/src/new.ts"), Some("pkg/src/old.ts"));
588 assert_eq!(index.old_path_for_root_relative("src/absent.ts"), None);
589 }
590
591 #[test]
592 fn root_relative_key_round_trips() {
593 let index = DiffIndex::default().with_root_offset("packages/pkg");
594 assert_eq!(
595 index.key_for_root_relative("src/a.ts"),
596 "packages/pkg/src/a.ts"
597 );
598 assert_eq!(
599 index
600 .root_relative_from_key("packages/pkg/src/a.ts")
601 .as_deref(),
602 Some("src/a.ts")
603 );
604 assert_eq!(index.root_relative_from_key("other/src/a.ts"), None);
606 assert_eq!(
608 index.root_relative_from_key("packages/pkg-extra/a.ts"),
609 None
610 );
611 }
612
613 #[test]
614 fn empty_root_offset_is_identity() {
615 let index = DiffIndex::default();
616 assert_eq!(index.key_for_root_relative("src/a.ts"), "src/a.ts");
617 assert_eq!(
618 index.root_relative_from_key("src/a.ts").as_deref(),
619 Some("src/a.ts")
620 );
621 }
622
623 #[test]
624 fn touched_files_enumerates_diff_header_paths() {
625 let diff = "\
626diff --git a/pkg/a.ts b/pkg/a.ts
627--- a/pkg/a.ts
628+++ b/pkg/a.ts
629@@ -0,0 +1,1 @@
630+x
631";
632 let index = DiffIndex::from_unified_diff(diff);
633 assert_eq!(index.touched_files().collect::<Vec<_>>(), vec!["pkg/a.ts"]);
634 }
635}