1use std::collections::HashSet;
22
23use forge_primitives::review::{ReviewAnchor, ReviewSide, is_doc_scope};
24
25use super::content::FileContent;
26use super::hunks::{DiffLine, FileHunks, Hunk};
27
28pub const CONTEXT_RADIUS: usize = 3;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AnchorResolution {
36 InPlace { file_idx: usize, hunk_idx: usize, line_idx: usize },
38 Moved { file_idx: usize, hunk_idx: usize, line_idx: usize, from: u32 },
41 Outdated(OutdatedReason),
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum OutdatedReason {
51 Gone,
54 Ambiguous { matches: usize },
57}
58
59pub fn content_hash_bytes(bytes: &[u8]) -> u64 {
65 const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
66 const PRIME: u64 = 0x0000_0100_0000_01b3;
67 let mut hash = OFFSET;
68 for byte in bytes {
69 hash ^= u64::from(*byte);
70 hash = hash.wrapping_mul(PRIME);
71 }
72 hash
73}
74
75pub fn content_hash(text: &str) -> u64 {
77 content_hash_bytes(text.as_bytes())
78}
79
80pub fn capture_context(hunk: &Hunk, line_idx: usize, radius: usize) -> Vec<String> {
83 let start = line_idx.saturating_sub(radius);
84 let end = line_idx.saturating_add(radius).saturating_add(1).min(hunk.lines.len());
85 (start..end).filter(|&i| i != line_idx).map(|i| hunk.lines[i].text.clone()).collect()
86}
87
88fn normalise_ws(text: &str) -> String {
90 text.split_whitespace().collect::<Vec<_>>().join(" ")
91}
92
93pub fn anchor_hash(text: &str) -> u64 {
96 content_hash(&normalise_ws(text))
97}
98
99fn text_matches(anchor: &ReviewAnchor, text: &str) -> bool {
104 anchor_hash(text) == anchor.content_hash || content_hash(text) == anchor.content_hash
105}
106
107fn neighbourhood_agrees<'a>(
115 anchor: &ReviewAnchor,
116 line_idx: usize,
117 len: usize,
118 line_at: impl Fn(usize) -> Option<&'a str>,
119) -> bool {
120 let recorded: HashSet<String> = anchor.context.iter().map(|l| normalise_ws(l)).collect();
121 let agrees = |i: usize| line_at(i).is_some_and(|l| recorded.contains(&normalise_ws(l)));
122 let before = (line_idx.saturating_sub(CONTEXT_RADIUS)..line_idx).any(agrees);
123 let after = (line_idx.saturating_add(1)
124 ..line_idx.saturating_add(CONTEXT_RADIUS).saturating_add(1).min(len))
125 .any(agrees);
126 let has_before = line_idx > 0;
128 let has_after = line_idx.saturating_add(1) < len;
129 (before || !has_before) && (after || !has_after) && (before || after)
130}
131
132fn neighbourhood_matches(anchor: &ReviewAnchor, hunk: &Hunk, line_idx: usize) -> bool {
133 neighbourhood_agrees(anchor, line_idx, hunk.lines.len(), |i| {
134 hunk.lines.get(i).map(|l| l.text.as_str())
135 })
136}
137
138pub fn resolve_anchor(
145 anchor: &ReviewAnchor,
146 files: &[FileHunks],
147 trust_line_number: bool,
148) -> AnchorResolution {
149 let located: Vec<(&str, &[Hunk])> =
150 files.iter().map(|file| (file.path.as_str(), file.hunks.as_slice())).collect();
151 resolve_in(anchor, &located, trust_line_number)
152}
153
154pub fn resolve_anchor_in_content(
158 anchor: &ReviewAnchor,
159 files: &[FileContent],
160 trust_line_number: bool,
161) -> AnchorResolution {
162 let located: Vec<(&str, &[Hunk])> =
163 files.iter().map(|file| (file.path.as_str(), file.hunks.as_slice())).collect();
164 resolve_in(anchor, &located, trust_line_number)
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum AnchorPlacement {
172 Placed { line: u32, moved_from: Option<u32> },
175 Outdated(OutdatedReason),
177}
178
179pub fn place_anchor_in_content(
181 anchor: &ReviewAnchor,
182 files: &[FileContent],
183 trust_line_number: bool,
184) -> AnchorPlacement {
185 let line_at = |file_idx: usize, hunk_idx: usize, line_idx: usize| {
186 files
187 .get(file_idx)
188 .and_then(|file| file.hunks.get(hunk_idx))
189 .and_then(|hunk| hunk.lines.get(line_idx))
190 .and_then(|line| side_line(line, anchor.side))
191 .unwrap_or(anchor.line)
194 };
195 match resolve_anchor_in_content(anchor, files, trust_line_number) {
196 AnchorResolution::InPlace { file_idx, hunk_idx, line_idx } => AnchorPlacement::Placed {
197 line: line_at(file_idx, hunk_idx, line_idx),
198 moved_from: None,
199 },
200 AnchorResolution::Moved { file_idx, hunk_idx, line_idx, from } => AnchorPlacement::Placed {
201 line: line_at(file_idx, hunk_idx, line_idx),
202 moved_from: Some(from),
203 },
204 AnchorResolution::Outdated(reason) => AnchorPlacement::Outdated(reason),
205 }
206}
207
208pub fn place_anchor_in_lines(anchor: &ReviewAnchor, lines: &[String]) -> AnchorPlacement {
216 place_anchor_in_lines_with(anchor, lines, true)
217}
218
219pub fn place_anchor_in_lines_with(
230 anchor: &ReviewAnchor,
231 lines: &[String],
232 trust_line_number: bool,
233) -> AnchorPlacement {
234 let line_at = |i: usize| lines.get(i).map(String::as_str);
235 if trust_line_number
236 && let Some(idx) = anchor.line.checked_sub(1).and_then(|i| usize::try_from(i).ok())
237 && let Some(text) = line_at(idx)
238 && text_matches(anchor, text)
239 {
240 return AnchorPlacement::Placed { line: anchor.line, moved_from: None };
241 }
242 let mut found: Vec<u32> = Vec::new();
243 for (idx, text) in lines.iter().enumerate() {
244 if text_matches(anchor, text) && neighbourhood_agrees(anchor, idx, lines.len(), line_at) {
245 found.push(u32::try_from(idx + 1).unwrap_or(u32::MAX));
246 }
247 }
248 match found[..] {
249 [] => AnchorPlacement::Outdated(OutdatedReason::Gone),
250 [line] => AnchorPlacement::Placed { line, moved_from: Some(anchor.line) },
251 _ => AnchorPlacement::Outdated(OutdatedReason::Ambiguous { matches: found.len() }),
252 }
253}
254
255pub fn place_document_anchor(anchor: &ReviewAnchor, lines: &[String]) -> AnchorPlacement {
265 let Some(first) = anchor.context.first() else {
266 return AnchorPlacement::Outdated(OutdatedReason::Gone);
267 };
268 let key = anchor_hash(first);
269 if let Some(idx) = anchor.line.checked_sub(1).and_then(|i| usize::try_from(i).ok())
270 && lines.get(idx).is_some_and(|text| anchor_hash(text) == key)
271 {
272 return AnchorPlacement::Placed { line: anchor.line, moved_from: None };
273 }
274 let found: Vec<u32> = lines
275 .iter()
276 .enumerate()
277 .filter(|(_, text)| anchor_hash(text) == key)
278 .map(|(idx, _)| u32::try_from(idx + 1).unwrap_or(u32::MAX))
279 .collect();
280 match found[..] {
281 [] => AnchorPlacement::Outdated(OutdatedReason::Gone),
282 [line] => AnchorPlacement::Placed { line, moved_from: Some(anchor.line) },
283 _ => AnchorPlacement::Outdated(OutdatedReason::Ambiguous { matches: found.len() }),
284 }
285}
286
287fn resolve_in(
288 anchor: &ReviewAnchor,
289 files: &[(&str, &[Hunk])],
290 trust_line_number: bool,
291) -> AnchorResolution {
292 if is_doc_scope(&anchor.base_ref) {
298 return AnchorResolution::Outdated(OutdatedReason::Gone);
299 }
300 let Some(file_idx) = files.iter().position(|(path, _)| *path == anchor.path) else {
301 return AnchorResolution::Outdated(OutdatedReason::Gone);
302 };
303 let (_, hunks) = &files[file_idx];
304
305 if trust_line_number {
307 for (hunk_idx, hunk) in hunks.iter().enumerate() {
308 for (line_idx, line) in hunk.lines.iter().enumerate() {
309 if side_line(line, anchor.side) == Some(anchor.line)
310 && text_matches(anchor, &line.text)
311 {
312 return AnchorResolution::InPlace { file_idx, hunk_idx, line_idx };
313 }
314 }
315 }
316 }
317
318 let mut found = Vec::new();
319 for (hunk_idx, hunk) in hunks.iter().enumerate() {
320 for (line_idx, line) in hunk.lines.iter().enumerate() {
321 if side_line(line, anchor.side).is_some()
322 && text_matches(anchor, &line.text)
323 && neighbourhood_matches(anchor, hunk, line_idx)
324 {
325 found.push((hunk_idx, line_idx));
326 }
327 }
328 }
329 match found[..] {
330 [] => AnchorResolution::Outdated(OutdatedReason::Gone),
331 [(hunk_idx, line_idx)] => {
332 AnchorResolution::Moved { file_idx, hunk_idx, line_idx, from: anchor.line }
333 }
334 _ => AnchorResolution::Outdated(OutdatedReason::Ambiguous { matches: found.len() }),
335 }
336}
337
338fn side_line(line: &DiffLine, side: ReviewSide) -> Option<u32> {
339 match side {
340 ReviewSide::Old => line.old_line,
341 ReviewSide::New => line.new_line,
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use crate::env::git_diff::hunks::{DiffLineKind, FileStatus};
349
350 #[test]
354 fn the_byte_hash_matches_the_text_hash_over_its_bytes() {
355 assert_eq!(content_hash_bytes(b"let x = 1;"), content_hash("let x = 1;"));
356 assert_ne!(content_hash_bytes(b"a"), content_hash_bytes(b"b"));
357 }
358
359 #[test]
364 fn a_doc_scoped_anchor_is_never_resolved() {
365 let files = vec![file("docs/plan.md", vec![new_line("# Plan", 1), new_line("body", 2)])];
366 let mut a = anchor("docs/plan.md", ReviewSide::New, 1, "# Plan", &[]);
367 a.base_ref = forge_primitives::review::doc_scope_ref("/proj/docs/plan.md");
368
369 assert_eq!(
370 resolve_anchor(&a, &files, true),
371 AnchorResolution::Outdated(OutdatedReason::Gone),
372 "a document is not a place in the diff's numbering",
373 );
374 }
375
376 fn new_line(text: &str, n: u32) -> DiffLine {
378 DiffLine {
379 kind: DiffLineKind::Added,
380 text: text.to_owned(),
381 old_line: None,
382 new_line: Some(n),
383 }
384 }
385
386 fn old_line(text: &str, n: u32) -> DiffLine {
388 DiffLine {
389 kind: DiffLineKind::Removed,
390 text: text.to_owned(),
391 old_line: Some(n),
392 new_line: None,
393 }
394 }
395
396 fn file(path: &str, lines: Vec<DiffLine>) -> FileHunks {
397 FileHunks {
398 path: path.to_owned(),
399 status: FileStatus::Modified,
400 hunks: vec![Hunk { old_start: 1, old_count: 0, new_start: 1, new_count: 0, lines }],
401 oversize: false,
402 }
403 }
404
405 fn content_file(path: &str, lines: Vec<DiffLine>) -> FileContent {
406 FileContent {
407 path: path.to_owned(),
408 old_path: None,
409 status: FileStatus::Modified,
410 binary: false,
411 submodule: false,
412 truncated: false,
413 hunks: vec![Hunk { old_start: 1, old_count: 0, new_start: 1, new_count: 0, lines }],
414 }
415 }
416
417 #[test]
421 fn the_content_read_resolves_like_the_overlay_scan() {
422 let lines = vec![new_line("fn a() {", 4), new_line(" body();", 5), new_line("}", 6)];
423 let overlay = vec![file("src/x.rs", lines.clone())];
424 let content = vec![content_file("src/x.rs", lines)];
425 let placed = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
426 assert_eq!(
427 resolve_anchor_in_content(&placed, &content, true),
428 resolve_anchor(&placed, &overlay, true),
429 );
430 let absent = anchor("src/y.rs", ReviewSide::New, 5, " body();", &[]);
431 assert_eq!(
432 resolve_anchor_in_content(&absent, &content, true),
433 AnchorResolution::Outdated(OutdatedReason::Gone),
434 );
435 }
436
437 #[test]
448 fn the_hash_vectors_the_web_client_mirrors() {
449 assert_eq!(anchor_hash("let x = 1;"), 12_051_066_714_322_805_831);
450 assert_eq!(anchor_hash(" a b "), 16_591_147_884_985_137_298);
451 assert_eq!(anchor_hash("fn caf\u{e9}() {}"), 14_060_598_129_659_224_412);
452 assert_eq!(anchor_hash("a\u{85}b"), 16_591_147_884_985_137_298);
453 assert_eq!(anchor_hash("a\u{feff}b"), 4_988_096_857_352_653_883);
454 }
455
456 #[test]
460 fn the_placement_names_the_line_it_moved_to() {
461 let content = vec![content_file(
462 "src/x.rs",
463 vec![new_line("fn a() {", 8), new_line(" body();", 9), new_line("}", 10)],
464 )];
465 let moved = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
466 assert_eq!(
467 place_anchor_in_content(&moved, &content, false),
468 AnchorPlacement::Placed { line: 9, moved_from: Some(5) },
469 );
470 let home = anchor("src/x.rs", ReviewSide::New, 9, " body();", &["fn a() {", "}"]);
471 assert_eq!(
472 place_anchor_in_content(&home, &content, true),
473 AnchorPlacement::Placed { line: 9, moved_from: None },
474 "in place draws no note",
475 );
476 }
477
478 #[test]
484 fn a_tree_anchor_places_in_place_and_moved_against_file_lines() {
485 let lines =
486 |texts: &[&str]| -> Vec<String> { texts.iter().map(|t| (*t).to_owned()).collect() };
487 let home = lines(&["fn f() {", " let a = 1;", " let b = 2;", "}", "// tail"]);
488 let placed = anchor(
489 "src/x.rs",
490 ReviewSide::New,
491 2,
492 " let a = 1;",
493 &["fn f() {", " let b = 2;"],
494 );
495 assert_eq!(
496 place_anchor_in_lines(&placed, &home),
497 AnchorPlacement::Placed { line: 2, moved_from: None },
498 );
499
500 let shifted = lines(&[
503 "// header",
504 "// header 2",
505 "fn f() {",
506 " let a = 1;",
507 " let b = 2;",
508 "}",
509 "// tail",
510 ]);
511 assert_eq!(
512 place_anchor_in_lines(&placed, &shifted),
513 AnchorPlacement::Placed { line: 4, moved_from: Some(2) },
514 );
515 }
516
517 #[test]
524 fn a_whole_file_places_a_line_the_hunks_do_not_carry() {
525 let lines =
526 |texts: &[&str]| -> Vec<String> { texts.iter().map(|t| (*t).to_owned()).collect() };
527 let file = lines(&["wide one", "wide two", "wide three", "wide four"]);
528 let revealed =
529 anchor("src/a.rs", ReviewSide::New, 2, "wide two", &["wide one", "wide three"]);
530 assert_eq!(
531 place_anchor_in_lines_with(&revealed, &file, true),
532 AnchorPlacement::Placed { line: 2, moved_from: None },
533 );
534 let old = anchor("src/a.rs", ReviewSide::Old, 2, "wide two", &["wide one", "wide three"]);
536 assert_eq!(
537 place_anchor_in_lines_with(&old, &file, true),
538 AnchorPlacement::Placed { line: 2, moved_from: None },
539 );
540
541 let lonely = anchor("src/a.rs", ReviewSide::New, 2, "wide two", &["nowhere near this"]);
545 assert_eq!(
546 place_anchor_in_lines_with(&lonely, &file, true),
547 AnchorPlacement::Placed { line: 2, moved_from: None },
548 );
549 assert_eq!(
550 place_anchor_in_lines_with(&lonely, &file, false),
551 AnchorPlacement::Outdated(OutdatedReason::Gone),
552 );
553
554 let gone = anchor("src/a.rs", ReviewSide::New, 2, "not in this file", &[]);
555 assert_eq!(
556 place_anchor_in_lines_with(&gone, &file, true),
557 AnchorPlacement::Outdated(OutdatedReason::Gone),
558 );
559 }
560
561 #[test]
565 fn a_tree_anchor_gone_and_ambiguous_read_as_the_diff_resolvers_reasons() {
566 let lines =
567 |texts: &[&str]| -> Vec<String> { texts.iter().map(|t| (*t).to_owned()).collect() };
568 let placed = anchor(
569 "src/x.rs",
570 ReviewSide::New,
571 2,
572 " let a = 1;",
573 &["fn f() {", " let b = 2;"],
574 );
575
576 let gone = lines(&["fn f() {", " let a = 9;", " let b = 2;", "}"]);
577 assert_eq!(
578 place_anchor_in_lines(&placed, &gone),
579 AnchorPlacement::Outdated(OutdatedReason::Gone),
580 );
581
582 let twice = lines(&[
585 "// header",
586 "fn f() {",
587 " let a = 1;",
588 " let b = 2;",
589 "}",
590 "fn f() {",
591 " let a = 1;",
592 " let b = 2;",
593 "}",
594 ]);
595 assert_eq!(
596 place_anchor_in_lines(&placed, &twice),
597 AnchorPlacement::Outdated(OutdatedReason::Ambiguous { matches: 2 }),
598 );
599 }
600
601 #[test]
609 fn a_document_anchor_is_placed_by_its_first_captured_line() {
610 let lines =
611 |texts: &[&str]| -> Vec<String> { texts.iter().map(|t| (*t).to_owned()).collect() };
612 let document = lines(&["# Plan", "", "the line", "after it"]);
613 let home = document_anchor(3, &["the line", "after it"]);
614 assert_eq!(
615 place_document_anchor(&home, &document),
616 AnchorPlacement::Placed { line: 3, moved_from: None },
617 );
618
619 let moved = lines(&["# Plan", "a new intro", "", "the line", "after it"]);
620 assert_eq!(
621 place_document_anchor(&home, &moved),
622 AnchorPlacement::Placed { line: 4, moved_from: Some(3) },
623 "the line moved down a line and is re-found by its own text",
624 );
625
626 let gone = lines(&["# Plan", "", "another line"]);
627 assert_eq!(
628 place_document_anchor(&home, &gone),
629 AnchorPlacement::Outdated(OutdatedReason::Gone),
630 );
631
632 let twice = lines(&["the line", "x", "the line", "y"]);
633 assert_eq!(
634 place_document_anchor(&document_anchor(2, &["the line"]), &twice),
635 AnchorPlacement::Outdated(OutdatedReason::Ambiguous { matches: 2 }),
636 "two homes for one line is a guess, not a move",
637 );
638
639 assert_eq!(
640 place_document_anchor(&document_anchor(2, &[]), &document),
641 AnchorPlacement::Outdated(OutdatedReason::Gone),
642 "nothing captured is nothing to place by",
643 );
644 }
645
646 fn document_anchor(line: u32, context: &[&str]) -> ReviewAnchor {
647 ReviewAnchor {
648 path: "/proj/docs/plan.md".to_owned(),
649 side: ReviewSide::New,
650 line,
651 content_hash: 0,
652 context: context.iter().map(|s| (*s).to_owned()).collect(),
653 base_ref: forge_primitives::review::doc_scope_ref("/proj/docs/plan.md"),
654 }
655 }
656
657 fn anchor(
658 path: &str,
659 side: ReviewSide,
660 line: u32,
661 text: &str,
662 context: &[&str],
663 ) -> ReviewAnchor {
664 ReviewAnchor {
665 path: path.to_owned(),
666 side,
667 line,
668 content_hash: anchor_hash(text),
669 context: context.iter().map(|s| (*s).to_owned()).collect(),
670 base_ref: "main".to_owned(),
671 }
672 }
673
674 #[test]
675 fn an_anchor_stored_before_normalisation_still_resolves() {
676 let files = vec![file(
679 "src/x.rs",
680 vec![new_line("fn a() {", 4), new_line(" body();", 5), new_line("}", 6)],
681 )];
682 let mut legacy = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
683 legacy.content_hash = content_hash(" body();");
684 assert_eq!(
685 resolve_anchor(&legacy, &files, true),
686 AnchorResolution::InPlace { file_idx: 0, hunk_idx: 0, line_idx: 1 },
687 "a raw-hash anchor still resolves rather than reading as gone",
688 );
689 }
690
691 #[test]
692 fn content_hash_is_deterministic_and_discriminating() {
693 assert_eq!(content_hash("let x = 1;"), content_hash("let x = 1;"));
694 assert_ne!(content_hash("let x = 1;"), content_hash("let x = 2;"));
695 }
696
697 #[test]
698 fn an_anchor_hash_ignores_whitespace_but_not_content() {
699 assert_eq!(anchor_hash(" foo()"), anchor_hash(" foo()"), "reindented is the same line");
700 assert_eq!(anchor_hash("a + b"), anchor_hash("a + b"), "and so is respaced");
701 assert_eq!(anchor_hash("foo() "), anchor_hash("foo()"), "trailing space is not content");
702 assert_ne!(anchor_hash("foo()"), anchor_hash("foo(x)"), "the code itself still counts");
703 assert_ne!(anchor_hash("a b"), anchor_hash("ab"), "collapsing runs never joins words");
704 }
705
706 #[test]
707 fn capture_context_excludes_center_and_clamps_at_edges() {
708 let hunk = Hunk {
709 old_start: 1,
710 old_count: 0,
711 new_start: 1,
712 new_count: 0,
713 lines: vec![new_line("a", 1), new_line("b", 2), new_line("c", 3), new_line("d", 4)],
714 };
715 assert_eq!(
717 capture_context(&hunk, 1, 2),
718 vec!["a".to_owned(), "c".to_owned(), "d".to_owned()]
719 );
720 assert_eq!(capture_context(&hunk, 0, 1), vec!["b".to_owned()]);
722 assert_eq!(capture_context(&hunk, 3, 1), vec!["c".to_owned()]);
724 }
725
726 #[test]
727 fn exact_line_and_content_is_in_place() {
728 let files = vec![file(
729 "src/x.rs",
730 vec![new_line("fn a() {", 4), new_line(" body();", 5), new_line("}", 6)],
731 )];
732 let a = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
733 assert_eq!(
734 resolve_anchor(&a, &files, true),
735 AnchorResolution::InPlace { file_idx: 0, hunk_idx: 0, line_idx: 1 },
736 );
737 }
738
739 #[test]
740 fn shifted_content_is_moved() {
741 let files = vec![file(
743 "src/x.rs",
744 vec![
745 new_line("inserted_1", 5),
746 new_line("inserted_2", 6),
747 new_line("fn a() {", 7),
748 new_line(" body();", 8),
749 new_line("}", 9),
750 ],
751 )];
752 let a = anchor("src/x.rs", ReviewSide::New, 6, " body();", &["fn a() {", "}"]);
754 assert_eq!(
755 resolve_anchor(&a, &files, true),
756 AnchorResolution::Moved { file_idx: 0, hunk_idx: 0, line_idx: 3, from: 6 },
757 );
758 }
759
760 #[test]
761 fn changed_content_is_outdated() {
762 let files = vec![file(
764 "src/x.rs",
765 vec![new_line("fn a() {", 4), new_line(" renamed_body();", 5), new_line("}", 6)],
766 )];
767 let a = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
768 assert_eq!(
769 resolve_anchor(&a, &files, true),
770 AnchorResolution::Outdated(OutdatedReason::Gone)
771 );
772 }
773
774 #[test]
775 fn absent_or_renamed_file_is_outdated() {
776 let files = vec![file("src/y.rs", vec![new_line(" body();", 5)])];
777 let a = anchor("src/x.rs", ReviewSide::New, 5, " body();", &[]);
778 assert_eq!(
779 resolve_anchor(&a, &files, true),
780 AnchorResolution::Outdated(OutdatedReason::Gone)
781 );
782 }
783
784 #[test]
785 fn resolves_in_a_later_file_and_later_hunk() {
786 let files = vec![
788 file("a.rs", vec![new_line("other", 1)]),
789 file("b.rs", vec![new_line("target", 5)]),
790 ];
791 let a = anchor("b.rs", ReviewSide::New, 5, "target", &[]);
792 assert_eq!(
793 resolve_anchor(&a, &files, true),
794 AnchorResolution::InPlace { file_idx: 1, hunk_idx: 0, line_idx: 0 },
795 );
796 let two_hunk = vec![FileHunks {
798 path: "c.rs".to_owned(),
799 status: FileStatus::Modified,
800 hunks: vec![
801 Hunk {
802 old_start: 1,
803 old_count: 0,
804 new_start: 1,
805 new_count: 0,
806 lines: vec![new_line("h0", 1)],
807 },
808 Hunk {
809 old_start: 10,
810 old_count: 0,
811 new_start: 10,
812 new_count: 0,
813 lines: vec![new_line("h1", 10)],
814 },
815 ],
816 oversize: false,
817 }];
818 let b = anchor("c.rs", ReviewSide::New, 10, "h1", &[]);
819 assert_eq!(
820 resolve_anchor(&b, &two_hunk, true),
821 AnchorResolution::InPlace { file_idx: 0, hunk_idx: 1, line_idx: 0 },
822 );
823 }
824
825 #[test]
826 fn old_side_anchor_resolves_against_old_line_numbers() {
827 let files = vec![file(
828 "src/x.rs",
829 vec![old_line("removed_a", 3), old_line("removed_b", 4), new_line("added", 3)],
830 )];
831 let a = anchor("src/x.rs", ReviewSide::Old, 4, "removed_b", &["removed_a"]);
832 assert_eq!(
833 resolve_anchor(&a, &files, true),
834 AnchorResolution::InPlace { file_idx: 0, hunk_idx: 0, line_idx: 1 },
835 );
836 }
837
838 #[test]
839 fn the_neighbourhood_picks_between_two_identical_lines() {
840 let files = vec![file(
844 "src/x.rs",
845 vec![
846 new_line(" let x = compute();", 10),
847 new_line(" return x;", 11),
848 new_line("}", 12),
849 new_line("", 13),
850 new_line("fn helper() {", 14),
851 new_line(" let y = other();", 15),
852 new_line(" log(y);", 16),
853 new_line(" return y;", 17),
854 new_line("}", 18),
855 ],
856 )];
857 let a = anchor(
858 "src/x.rs",
859 ReviewSide::New,
860 14,
861 "}",
862 &[" let y = other();", " log(y);", " return y;"],
863 );
864 assert_eq!(
865 resolve_anchor(&a, &files, true),
866 AnchorResolution::Moved { file_idx: 0, hunk_idx: 0, line_idx: 8, from: 14 },
867 "only helper's brace has the recorded neighbours; the closer brace is not a candidate",
868 );
869 }
870
871 #[test]
872 fn a_reindented_line_is_the_same_anchor() {
873 let files = vec![file(
876 "src/x.rs",
877 vec![new_line("fn a() {", 4), new_line(" body();", 5), new_line("}", 6)],
878 )];
879 let a = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
880 assert_eq!(
881 resolve_anchor(&a, &files, true),
882 AnchorResolution::InPlace { file_idx: 0, hunk_idx: 0, line_idx: 1 },
883 "indentation is not identity",
884 );
885 }
886
887 #[test]
888 fn two_matching_locations_do_not_relocate() {
889 let files = vec![file(
893 "src/x.rs",
894 vec![
895 new_line("fn helper() {", 10),
896 new_line(" log(x);", 11),
897 new_line("}", 12),
898 new_line("fn helper() {", 20),
899 new_line(" log(x);", 21),
900 new_line("}", 22),
901 ],
902 )];
903 let a = anchor("src/x.rs", ReviewSide::New, 40, " log(x);", &["fn helper() {", "}"]);
904 assert_eq!(
905 resolve_anchor(&a, &files, true),
906 AnchorResolution::Outdated(OutdatedReason::Ambiguous { matches: 2 }),
907 "two candidates is not a relocation, it is a coin toss",
908 );
909 }
910
911 #[test]
912 fn a_line_whose_neighbours_all_changed_does_not_relocate() {
913 let files = vec![file(
916 "src/x.rs",
917 vec![new_line("fn other() {", 30), new_line(" different();", 31), new_line("}", 32)],
918 )];
919 let a = anchor(
920 "src/x.rs",
921 ReviewSide::New,
922 12,
923 "}",
924 &[" let x = compute();", " return x;"],
925 );
926 assert_eq!(
927 resolve_anchor(&a, &files, true),
928 AnchorResolution::Outdated(OutdatedReason::Gone),
929 "the line alone is not enough to move a comment onto",
930 );
931 }
932
933 #[test]
934 fn a_match_far_from_its_old_line_still_relocates() {
935 let files = vec![file(
938 "src/x.rs",
939 vec![new_line("fn a() {", 500), new_line(" body();", 501), new_line("}", 502)],
940 )];
941 let a = anchor("src/x.rs", ReviewSide::New, 5, " body();", &["fn a() {", "}"]);
942 assert_eq!(
943 resolve_anchor(&a, &files, true),
944 AnchorResolution::Moved { file_idx: 0, hunk_idx: 0, line_idx: 1, from: 5 },
945 "a unique match relocates however far it travelled, and names where it came from",
946 );
947 }
948
949 #[test]
950 fn a_line_number_from_another_numbering_is_not_evidence() {
951 let files = vec![file(
957 "src/x.rs",
958 vec![
959 new_line("fn zzz() {", 6),
960 new_line("}", 7),
961 new_line("fn helper() {", 20),
962 new_line(" log(x);", 21),
963 new_line("}", 22),
964 ],
965 )];
966 let a = anchor("src/x.rs", ReviewSide::New, 7, "}", &["fn helper() {", " log(x);"]);
967 assert_eq!(
968 resolve_anchor(&a, &files, false),
969 AnchorResolution::Moved { file_idx: 0, hunk_idx: 0, line_idx: 4, from: 7 },
970 "the neighbours decide, not a line number counted in another space",
971 );
972 }
973
974 #[test]
975 fn a_line_number_from_the_threads_own_view_is_still_trusted() {
976 let files = vec![file("src/x.rs", vec![new_line(" body();", 5)])];
980 let a = anchor("src/x.rs", ReviewSide::New, 5, " body();", &[]);
981 assert_eq!(
982 resolve_anchor(&a, &files, true),
983 AnchorResolution::InPlace { file_idx: 0, hunk_idx: 0, line_idx: 0 },
984 );
985 }
986}