Skip to main content

forge_agent/env/git_diff/
resolver.rs

1//! Re-anchoring resolver for durable review threads.
2//!
3//! A persisted [`ReviewAnchor`] stores a drift-robust location (path +
4//! side + line + content hash + a few context lines). The diff is
5//! re-scanned on every overlay open, so the anchor must be re-resolved
6//! to a fresh positional index into the new [`FileHunks`].
7//! [`resolve_anchor`] reports whether the line is still in place, moved,
8//! or could not be placed at all.
9//!
10//! Relocation requires one unambiguous match: the line, plus at least
11//! one recorded neighbour agreeing within [`CONTEXT_RADIUS`] on each
12//! side the candidate has neighbours. Anything less is left where it was
13//! and marked outdated, because a comment silently attached to the wrong
14//! code costs far more than one the reviewer has to go looking for.
15//!
16//! A distance bound does not make that safer. Bounding how far a match
17//! may be found still leaves several candidates inside the bound, and
18//! choosing among them by proximity is exactly the guess uniqueness
19//! refuses to make.
20
21use 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
28/// Lines captured on each side of an anchored line for fuzzy re-anchor
29/// disambiguation.
30pub const CONTEXT_RADIUS: usize = 3;
31
32/// Where a re-resolved anchor landed against a fresh diff scan. Indices
33/// address `files[file_idx].hunks[hunk_idx].lines[line_idx]`.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AnchorResolution {
36    /// Still at its recorded line number with the same content.
37    InPlace { file_idx: usize, hunk_idx: usize, line_idx: usize },
38    /// One unambiguous match elsewhere. `from` is the line it left, which
39    /// the thread reports so a relocation is never silent.
40    Moved { file_idx: usize, hunk_idx: usize, line_idx: usize, from: u32 },
41    /// Not relocated. The thread renders against its captured context,
42    /// saying why.
43    Outdated(OutdatedReason),
44}
45
46/// Why an anchor did not relocate. Attaching a comment to the wrong code
47/// costs more than leaving it behind, so anything short of one certain
48/// match lands here rather than being guessed at.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum OutdatedReason {
51    /// The anchored code appears nowhere in the current diff, or its file
52    /// is absent or renamed.
53    Gone,
54    /// Several locations match it equally well, so picking one would be a
55    /// guess.
56    Ambiguous { matches: usize },
57}
58
59/// FNV-1a hash of raw bytes: what both a line's text and a whole document
60/// go through, so one implementation pins both.
61///
62/// Deterministic across runs (unlike `DefaultHasher`), which matters because
63/// the value is persisted in an anchor and compared after restarts.
64pub 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
75/// [`content_hash_bytes`] over a diff line's text.
76pub fn content_hash(text: &str) -> u64 {
77    content_hash_bytes(text.as_bytes())
78}
79
80/// Capture up to `radius` lines on each side of `line_idx` within `hunk`
81/// (excluding the line itself) as fuzzy-match context.
82pub 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
88/// Collapse every run of whitespace to one space and trim the ends.
89fn normalise_ws(text: &str) -> String {
90    text.split_whitespace().collect::<Vec<_>>().join(" ")
91}
92
93/// The hash to persist for a newly captured anchor. Whitespace-normalised
94/// so a `cargo fmt` reflow reads as the same line.
95pub fn anchor_hash(text: &str) -> u64 {
96    content_hash(&normalise_ws(text))
97}
98
99/// Whether `text` is the line `anchor` was placed on. Anchors captured
100/// before normalisation hold the raw hash, so both forms are accepted;
101/// such an anchor keeps its old exact-match behaviour until it is
102/// rewritten.
103fn text_matches(anchor: &ReviewAnchor, text: &str) -> bool {
104    anchor_hash(text) == anchor.content_hash || content_hash(text) == anchor.content_hash
105}
106
107/// Whether the code around `line_idx` still looks like where the comment
108/// was left. Each side the candidate has neighbours on must carry one of
109/// the anchor's recorded lines within [`CONTEXT_RADIUS`], in any order. A
110/// line on its own matches far too easily - a closing brace, a short
111/// call - so a neighbour has to agree. One implementation over any
112/// line-addressable slice, so the hunk resolver and the seat-tree one
113/// cannot drift.
114fn 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    // A hunk edge has no neighbour on that side, so nothing to agree with.
127    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
138/// Re-resolve `anchor` against a fresh `files` scan.
139/// `trust_line_number` says whether `files` is numbered the same way the
140/// anchor was recorded in. Only then does the recorded line carry
141/// information; from another scope's diff that number addresses whatever
142/// happens to sit there, so the match has to be earned by content and
143/// neighbours like any other.
144pub 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
154/// Re-resolve `anchor` against the socket's bounded content read, whose
155/// files carry hunks but not the overlay scan's own shape. Same
156/// resolution as [`resolve_anchor`], one implementation.
157pub 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/// One anchor's landing in the terms a view draws it: no positional
168/// indices into one scan's arrays, just the line the anchor sits on now
169/// and, when it could not be placed, the resolver's own reason.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171pub enum AnchorPlacement {
172    /// Placed, at `line` in the anchor's own side's numbering.
173    /// `moved_from` is the recorded line when that is not where it sits.
174    Placed { line: u32, moved_from: Option<u32> },
175    /// Not placed, for [`OutdatedReason`]'s reason.
176    Outdated(OutdatedReason),
177}
178
179/// [`resolve_anchor_in_content`], narrowed to what a view draws.
180pub 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            // The resolver only lands on lines carrying the anchor's own
192            // side, so this is its recorded line at worst.
193            .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
208/// One seat-tree anchor placed against a file's whole current lines - the
209/// files page's resolution. The scope's numbering IS the file's own (the
210/// seat's tree is where the comment was written), so unlike a diff scope
211/// whose numbering may belong to another base, the recorded line is
212/// trusted: in place when it still carries the anchor's text, otherwise
213/// re-found by content plus neighbours, and `Gone` / `Ambiguous` as the
214/// diff resolver reports them.
215pub fn place_anchor_in_lines(anchor: &ReviewAnchor, lines: &[String]) -> AnchorPlacement {
216    place_anchor_in_lines_with(anchor, lines, true)
217}
218
219/// The whole-file placement with the diff resolver's trust rule: with
220/// `trust_line_number` the recorded line is evidence too, because the file
221/// handed in is numbered the way the anchor was written; otherwise the match
222/// is earned by content and neighbours alone.
223///
224/// The caller hands the file at the revision the anchor's side is numbered
225/// in - the branch tip for a new-side line, the merge-base for an old-side
226/// one, because the branch-ahead diff is cut against the merge-base.
227/// A comment written on a revealed context line is outside the diff's hunks
228/// by construction, and this is where its line is found.
229pub 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
255/// A document anchor placed against the document's whole current lines:
256/// the thread's FIRST captured line is the key, looked for by content.
257///
258/// A document has no hunks, and a rendered block's neighbours are not the
259/// anchor's neighbours the way a code line's are, so the code resolver's
260/// neighbourhood rule is not this one. Identity is the captured line's own
261/// text: in place at the recorded number, otherwise one unique match moves
262/// it, and two matches are a guess the placement refuses exactly as the
263/// code rule does.
264pub 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    // A doc scope is never a place in a diff, however its path collides
293    // with a changed file: the viewer places those threads, not this. The
294    // guard lives in the ONE implementation, so both entry points - the
295    // overlay's scan and the socket's content read - refuse a doc anchor
296    // alike.
297    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    // In place: the recorded line number still carrying the same content.
306    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    /// The byte hash is the same function the text hash is, so a document's
351    /// hash and a line's hash share one implementation: text hashing is
352    /// this over its UTF-8 bytes.
353    #[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    /// **A doc-scoped anchor is never placed against a diff.** The scope
360    /// spells a document, and the viewer places those threads, not the
361    /// resolver: without the guard a document whose path coincides with a
362    /// changed file could have its comments resolved onto code.
363    #[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    /// A `new`-side line (added or context) at `new_line`.
377    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    /// An `old`-side line (removed) at `old_line`.
387    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    /// The socket path resolves exactly as the overlay's own scan does -
418    /// one resolution, two content shapes - and a file the content does
419    /// not carry is the same Gone the overlay reports.
420    #[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    /// The values the web client's own `anchorHash` is pinned against: the
438    /// page computes this hash for the anchors it writes, and the two
439    /// implementations must agree byte for byte or a thread written there
440    /// never resolves. Whitespace collapsing and a non-ASCII
441    /// byte are both exercised, because the two are where a port drifts.
442    ///
443    /// **The last two vectors are the collapse's edges**, where JS's `\s`
444    /// and Rust's `split_whitespace` disagree: U+0085 (NEL) is Unicode
445    /// White_Space and splits, U+FEFF (the BOM) is not and stays. The JS
446    /// side names the class explicitly for exactly these two.
447    #[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    /// The view-shaped placement names the line it landed on and the one
457    /// it left - the two numbers a client draws, neither of which is an
458    /// index into this scan.
459    #[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    /// A seat-tree anchor resolves against the file's whole current lines:
479    /// the recorded number is the file's own numbering (the tree WAS the
480    /// anchor's home when it was written), so the in-place shortcut
481    /// applies, and a shifted line is re-found by content plus neighbours
482    /// with the recorded line named as `moved_from`.
483    #[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        // The same content two lines down, neighbours intact: moved, and
501        // the recorded line is named.
502        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    /// **A comment written on a revealed line is placed in the whole file.**
518    /// The line is outside the diff's hunks by construction, so the content
519    /// read cannot carry it; the file at the revision the anchor's side is
520    /// numbered in does. The diff resolver's trust rule holds there too: the
521    /// recorded number is evidence only when it is the anchor's own
522    /// numbering, and text the file does not carry is still gone.
523    #[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        // The old side is handed the base's file, whose numbering is its own.
535        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        // Trusted, the recorded line alone places it; untrusted, the same
542        // line has to earn the match with a neighbour, and this one has
543        // none to agree with.
544        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    /// The reasons match the diff resolver's: content gone is `Gone`;
562    /// content plus neighbours matching twice is `Ambiguous`, because
563    /// picking one would be the guess uniqueness refuses to make.
564    #[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        // Both copies away from the recorded line, so the in-place
583        // shortcut cannot win: content plus neighbours match twice.
584        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    /// **A document thread is placed by its FIRST captured line's
602    /// content.** The recorded number still means what it says when that
603    /// line is unchanged; a revision that moved the line re-anchors it to
604    /// the one place its text now sits, and text that is gone - or that
605    /// now sits in two places - is not placed at all. A document's anchor
606    /// has no code neighbourhood to agree with, so the captured line
607    /// itself is the whole of its identity.
608    #[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        // Rows written when the hash was raw keep exact-match behaviour
677        // rather than failing to resolve at all.
678        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        // Middle: one before, two after (clamped by len).
716        assert_eq!(
717            capture_context(&hunk, 1, 2),
718            vec!["a".to_owned(), "c".to_owned(), "d".to_owned()]
719        );
720        // First line: nothing before.
721        assert_eq!(capture_context(&hunk, 0, 1), vec!["b".to_owned()]);
722        // Last line: nothing after.
723        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        // The line drifted down two (a commit inserted lines above).
742        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        // Recorded at line 6, content unchanged.
753        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        // The anchored content no longer appears anywhere in the file.
763        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        // file_idx 1: the target lives in the second file.
787        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        // hunk_idx 1: the target lives in the second hunk.
797        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        // Two identical `}` lines. The wrong one (line 12) is closer to
841        // the recorded line 14; only the right one (line 18) sits among
842        // the recorded neighbours, so exactly one candidate matches.
843        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        // cargo fmt reflows indentation on every commit in this repo. That
874        // is the same code, and an exact match would strand the comment.
875        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        // The same line with the same neighbours twice. Picking one would
890        // be a guess, and a wrong guess attaches the comment to code it
891        // was never about.
892        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        // A bare brace matches almost anywhere; the neighbourhood is what
914        // identifies it.
915        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        // A rebase moves code hundreds of lines. Distance is not evidence
936        // of a different line once the neighbourhood agrees.
937        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        // A commit-scoped anchor counts lines against `sha^..sha`. In the
952        // whole-branch diff that number addresses unrelated code, so the
953        // in-place shortcut - which trusts the number and skips the
954        // neighbour check - lands the comment inside a different
955        // function. Braces and short lines collide constantly.
956        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        // In its own numbering the recorded line means what it says, so
977        // an unchanged line resolves without needing neighbours - which
978        // is what keeps a one-line hunk working.
979        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}