travelagent-core 1.10.0

Core library for travelagent code review tool
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
//! Pure re-anchoring pipeline for the live-review rescan loop.
//!
//! When the working tree changes underneath an open review session, the
//! diff's "new side" line numbers shift. [`reanchor_comments`] walks every
//! file in a [`ReviewSession`], builds an [`AnchorMap`] between the
//! pre-rescan "new side" snapshot and the post-rescan content, and re-keys
//! each file's `line_comments` map accordingly. Comments whose line
//! disappeared are moved to `orphaned_comments` with their last-seen
//! content preserved so the user can surface / re-anchor them later.
//!
//! # Why this lives in `core`
//!
//! The function is pure: no I/O, no TUI state, no `App`. Callers are
//! responsible for reading file content from disk (or wherever) and passing
//! it in via `new_content_map`. Keeping the pure core here lets the TUI,
//! MCP server, and future headless `ReviewEngine` share the same anchor
//! semantics without each re-implementing them.
//!
//! # Missing-content policy
//!
//! When a tracked path is absent from `new_content_map` the caller's
//! preload failed (or the file vanished from disk); we treat those cases
//! differently:
//!
//! * If `path_filter_active` is set, filtered-out files are left alone —
//!   the file may still exist on disk, we just aren't showing it.
//! * If the path is in `new_paths` (the rescan saw it) but missing from
//!   `new_content_map`, the preload returned `Err`. Skip re-anchoring for
//!   this file — preserve existing anchor state rather than silently
//!   orphaning every comment. Callers should log the error (pending
//!   `ErrorLog` from H8).
//! * If the path is NOT in `new_paths` at all, the file truly disappeared;
//!   orphan its comments (preserving the pre-H4 behavior).

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use crate::anchor_map::AnchorMap;
use crate::model::review::{FileReview, ReviewSession};
use crate::model::{AnchorState, Comment, DiffFile, FileStatus, LineSide};

/// Remap `old_new_content` keys in-place: for every `FileStatus::Renamed`
/// entry in `diff_files`, move the snapshot at `old_path` to `new_path`.
/// Caller must invoke this BEFORE running [`reanchor_comments`] on a
/// session that has already absorbed the same `diff_files` via
/// `apply_diff_files` — the session now keys files by the new path, so
/// the pre-rescan snapshot must be rekeyed too or every line comment on
/// a renamed file silently orphans (H6.11).
///
/// Safe to call with an unchanged `diff_files` — non-`Renamed` entries
/// and renames whose old path isn't in the map are ignored.
///
/// **Collision-safe two-pass implementation.** A naive single-pass insert
/// would clobber an existing `new_path` snapshot: imagine a diff with
/// rename `A→B` where `B` was also in the pre-rescan snapshot (because it
/// was separately touched). `insert(B, A_content)` would drop B's own
/// pre-rescan content, and subsequent re-anchoring of comments already
/// living on B would run against A's text — wrong line mappings. We
/// instead stage sources first (via `remove`), then insert, skipping
/// destinations that still hold a distinct snapshot. This also means A↔B
/// swaps are handled correctly: pass 1 removes both A and B, pass 2
/// re-inserts them at their new keys.
///
/// **Transitive rename chains.** For a diff containing both `A→B` and
/// `B→C` (rare outside aggregated forge diffs), we resolve each source
/// to its terminal destination via [`resolve_terminal_dest`] before
/// staging. So A's snapshot lands at C, B's also at C — the final
/// `.or_insert` keeps whichever settles first; the other is dropped
/// because both describe the pre-rescan content of paths that no
/// longer exist post-rescan.
///
/// **Collision policy — intentional divergence from `apply_diff_files`.**
/// On a pass-2 collision (rename `A→B` where `B` already has a snapshot),
/// this function keeps the destination's snapshot via `.or_insert` and
/// drops the source's. The sibling policy on `FileReview` state, in
/// [`ReviewSession::apply_diff_files`], instead *merges* via
/// [`FileReview::absorb_rename_source`] — which orphans source line
/// comments rather than remapping them. The two decisions are coupled:
/// because we keep the destination's *content*, source line comments
/// would re-anchor against the wrong text, so `absorb_rename_source`
/// orphans them instead. Keep both policies in sync if either changes.
pub fn remap_rename_keys(old_new_content: &mut HashMap<PathBuf, String>, diff_files: &[DiffFile]) {
    let direct = direct_rename_map(diff_files);

    // Pass 1: extract every rename source out of the map, keyed by
    // terminal destination (not direct destination) so transitive
    // chains `A→B, B→C` land A and B both at C.
    let mut staged: Vec<(PathBuf, String)> = Vec::new();
    for src in direct.keys() {
        if let Some(content) = old_new_content.remove(src) {
            let dst = resolve_terminal_dest(src, &direct);
            staged.push((dst, content));
        }
    }

    // Pass 2: settle staged entries. If a destination still holds a
    // snapshot (i.e. it was not itself a rename source and happened to be
    // touched independently in the same rescan), leave it alone — the
    // destination's own comments re-anchor against its own content, and
    // the renamed-source content would produce wrong mappings.
    for (new_path, content) in staged {
        old_new_content.entry(new_path).or_insert(content);
    }
}

/// Build a `src → dst` map from every `FileStatus::Renamed` entry where
/// the paths differ. Used by chain resolution.
pub(crate) fn direct_rename_map(diff_files: &[DiffFile]) -> HashMap<PathBuf, PathBuf> {
    let mut map = HashMap::new();
    for file in diff_files {
        if file.status == FileStatus::Renamed
            && let (Some(old), Some(new)) = (file.old_path.as_ref(), file.new_path.as_ref())
            && old.as_path() != new.as_path()
        {
            map.insert(old.clone(), new.clone());
        }
    }
    map
}

/// Walk the rename chain starting at `src` and return the terminal
/// destination — the last path that is not itself a rename source in
/// `direct`, OR (on cycle) the last fresh hop before the cycle closes.
///
/// For a two-path swap `A→B, B→A`, resolving from A walks A→B, sees
/// B→A would revisit A, stops and returns B. From B it returns A. So
/// the swap round-trips both paths to each other, matching the
/// established swap semantics.
pub(crate) fn resolve_terminal_dest(src: &Path, direct: &HashMap<PathBuf, PathBuf>) -> PathBuf {
    let mut current = src.to_path_buf();
    let mut visited: HashSet<PathBuf> = HashSet::new();
    visited.insert(current.clone());
    while let Some(next) = direct.get(&current) {
        if visited.contains(next) {
            // The next hop would revisit a path we've already walked
            // through (a cycle — usually a swap). Don't follow it;
            // `current` is the terminal for this branch of the cycle.
            return current;
        }
        visited.insert(next.clone());
        current = next.clone();
    }
    current
}

/// Look up the post-rescan path for a pre-rescan path. Returns `Some(new)`
/// when `path` appears as `old_path` on a `FileStatus::Renamed` entry in
/// `diff_files`, else `None`. Callers that forward a cursor or lookup
/// path through a rename use this instead of mutating a map.
///
/// Chain-aware: if a diff contains `A→B, B→C`, `remap_path(A, diff)` walks
/// through to the terminal destination `C` (not the intermediate `B`),
/// so a cursor on `A` lands on the same terminal the session-state and
/// content-snapshot migration landed on. Matches the policy in
/// [`ReviewSession::apply_diff_files`] and [`remap_rename_keys`].
pub fn remap_path(path: &Path, diff_files: &[DiffFile]) -> Option<PathBuf> {
    let direct = direct_rename_map(diff_files);
    if !direct.contains_key(path) {
        return None;
    }
    Some(resolve_terminal_dest(path, &direct))
}

/// Pure core of the L2 comment-survival pipeline. Walks every file in
/// `session.files`, builds an [`AnchorMap`] between the pre-rescan "new
/// side" snapshot (`old_new_content`) and the post-rescan content
/// (`new_content_map`), and re-keys the `line_comments` map accordingly.
/// Comments whose line disappeared move to `orphaned_comments` with their
/// last-seen content preserved.
///
/// No I/O. See the module-level docs for the missing-content policy when a
/// path is absent from `new_content_map`.
pub fn reanchor_comments(
    session: &mut ReviewSession,
    old_new_content: &HashMap<PathBuf, String>,
    new_content_map: &HashMap<PathBuf, String>,
    new_paths: &HashSet<PathBuf>,
    path_filter_active: bool,
) {
    for (path, review) in session.files.iter_mut() {
        // Legacy: side-only comments with no explicit anchor get stamped
        // with `Anchored { line, side }` for their current HashMap key on
        // first touch. We do this so subsequent rescans have a concrete
        // anchor state to roll forward.
        stamp_anchors_from_keys(review);

        if review.line_comments.is_empty() {
            continue;
        }

        // File disappeared entirely: orphan every comment. But if
        // `path_filter` is active the file may just be hidden — we can't
        // tell disappeared-from-disk apart from filtered-out here, so
        // leave filtered files untouched.
        if !new_paths.contains(path) {
            if path_filter_active {
                continue;
            }
            let old_content = old_new_content.get(path).cloned().unwrap_or_default();
            let old_lines: Vec<&str> = old_content.lines().collect();
            let line_comments: HashMap<u32, Vec<Comment>> =
                std::mem::take(&mut review.line_comments);
            for (line, comments) in line_comments {
                for comment in comments {
                    let side = comment_side(&comment, line);
                    // `old_content` is the pre-rescan NEW-side snapshot, so
                    // indexing it by a `LineSide::Old` comment's base-commit
                    // line number returns unrelated new-side text and
                    // confuses the orphaned-section UI. Skip the lookup for
                    // old-side orphans — the UI already handles an empty
                    // `last_seen_content`.
                    let last_seen = if side == LineSide::Old {
                        String::new()
                    } else {
                        old_lines
                            .get((line as usize).saturating_sub(1))
                            .map(|s| s.to_string())
                            .unwrap_or_default()
                    };
                    review.orphan_comment(line, side, last_seen, comment);
                }
            }
            continue;
        }

        // Path is tracked by the rescan but the preload couldn't read
        // it. Preserve existing anchors — do NOT orphan, do NOT silently
        // treat as empty. The previous implementation here read the
        // file with `unwrap_or_default()`, which degenerated to an
        // `AnchorMap::from_content("", "")` (identity) or `("", non-empty)`
        // (no mapping) and either silently skipped or orphaned every
        // comment depending on whether `old_content` was also empty.
        let Some(new_content) = new_content_map.get(path) else {
            continue;
        };

        let old_content = old_new_content.get(path).cloned().unwrap_or_default();
        let anchor_map = AnchorMap::from_content(&old_content, new_content);

        // Short-circuit if nothing moved. Legacy comments that lacked an
        // explicit anchor were just stamped above, so this branch is safe
        // to return to without re-anchoring.
        if anchor_map.is_identity() {
            continue;
        }

        let old_lines: Vec<&str> = old_content.lines().collect();
        let original: HashMap<u32, Vec<Comment>> = std::mem::take(&mut review.line_comments);
        let mut next: HashMap<u32, Vec<Comment>> = HashMap::new();

        for (line, comments) in original {
            for mut comment in comments {
                let side = comment_side(&comment, line);
                // The AnchorMap only covers the new side. Deletion-side
                // comments (`LineSide::Old`) reference lines that no
                // longer exist in the new-side content, so a new-side
                // lookup would be meaningless. Preserve the old-side key
                // as-is; if the file was re-added later and the old line
                // was restored, subsequent rescans can still reconcile.
                if side == LineSide::Old {
                    next.entry(line).or_default().push(comment);
                    continue;
                }
                match anchor_map.lookup(line) {
                    Some(new_line) => {
                        // Preserve any existing `reanchored_at` stamp
                        // — the comment was already anchored before
                        // this rescan, we're just shifting its line
                        // number, not re-anchoring from an orphan.
                        let reanchored_at = match comment.anchor.as_ref() {
                            Some(AnchorState::Anchored { reanchored_at, .. }) => *reanchored_at,
                            _ => None,
                        };
                        comment.anchor = Some(AnchorState::Anchored {
                            line: new_line,
                            side,
                            reanchored_at,
                        });
                        next.entry(new_line).or_default().push(comment);
                    }
                    None => {
                        let last_seen = old_lines
                            .get((line as usize).saturating_sub(1))
                            .map(|s| s.to_string())
                            .unwrap_or_default();
                        review.orphan_comment(line, side, last_seen, comment);
                    }
                }
            }
        }
        review.line_comments = next;
    }
}

/// Resolve the side a comment lives on. Falls back to `New` when the
/// comment's `side` field is absent (legacy) — the HashMap key is the
/// line number; for deletion comments the map uses the old line and
/// `side = Old`, but a missing field defaults to `New`, matching the
/// shape `Comment::new` would have produced.
fn comment_side(comment: &Comment, _fallback_line: u32) -> LineSide {
    comment.side.unwrap_or(LineSide::New)
}

/// Stamp `Anchored { line, side }` onto every comment that still has
/// `anchor == None`. Idempotent — comments that already carry an anchor
/// are left alone. Run on first rescan so subsequent passes can treat
/// `anchor` as authoritative.
fn stamp_anchors_from_keys(review: &mut FileReview) {
    for (line, comments) in review.line_comments.iter_mut() {
        for comment in comments.iter_mut() {
            if comment.anchor.is_none() {
                let side = comment.side.unwrap_or(LineSide::New);
                comment.anchor = Some(AnchorState::Anchored {
                    line: *line,
                    side,
                    reanchored_at: None,
                });
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::comment::CommentType;
    use crate::model::{DiffFile, FileStatus};

    fn df_renamed(old: &str, new: &str) -> DiffFile {
        DiffFile {
            old_path: Some(PathBuf::from(old)),
            new_path: Some(PathBuf::from(new)),
            status: FileStatus::Renamed,
            hunks: vec![],
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        }
    }

    fn df_modified(path: &str) -> DiffFile {
        DiffFile {
            old_path: Some(PathBuf::from(path)),
            new_path: Some(PathBuf::from(path)),
            status: FileStatus::Modified,
            hunks: vec![],
            is_binary: false,
            is_too_large: false,
            is_commit_message: false,
        }
    }

    #[test]
    fn remap_rename_keys_moves_snapshot_to_new_path() {
        let mut map: HashMap<PathBuf, String> = HashMap::new();
        map.insert(PathBuf::from("old.rs"), "hello\n".into());
        map.insert(PathBuf::from("unchanged.rs"), "other\n".into());

        remap_rename_keys(&mut map, &[df_renamed("old.rs", "new.rs")]);

        assert!(!map.contains_key(Path::new("old.rs")));
        assert_eq!(
            map.get(Path::new("new.rs")).map(String::as_str),
            Some("hello\n")
        );
        assert_eq!(
            map.get(Path::new("unchanged.rs")).map(String::as_str),
            Some("other\n"),
            "non-renamed entries are left alone"
        );
    }

    #[test]
    fn remap_rename_keys_ignores_non_renamed() {
        let mut map: HashMap<PathBuf, String> = HashMap::new();
        map.insert(PathBuf::from("a.rs"), "x\n".into());
        remap_rename_keys(&mut map, &[df_modified("a.rs")]);
        assert_eq!(map.get(Path::new("a.rs")).map(String::as_str), Some("x\n"));
    }

    #[test]
    fn remap_rename_keys_preserves_destination_snapshot_on_collision() {
        // Regression: a diff containing `A→B` where `B` already has its own
        // pre-rescan snapshot (because B was also touched) must not clobber
        // B's snapshot with A's content. B's own comments re-anchor against
        // B's own content; A's renamed-over content would produce wrong
        // line mappings.
        let mut map: HashMap<PathBuf, String> = HashMap::new();
        map.insert(PathBuf::from("a.rs"), "a-content\n".into());
        map.insert(PathBuf::from("b.rs"), "b-content\n".into());

        remap_rename_keys(&mut map, &[df_renamed("a.rs", "b.rs")]);

        assert!(
            !map.contains_key(Path::new("a.rs")),
            "source entry removed even on collision"
        );
        assert_eq!(
            map.get(Path::new("b.rs")).map(String::as_str),
            Some("b-content\n"),
            "destination's own snapshot is preserved"
        );
    }

    #[test]
    fn remap_rename_keys_swaps_two_paths_without_loss() {
        // Regression: a diff containing `A→B` AND `B→A` (a swap) must end
        // with A holding B's old content and B holding A's old content.
        // Single-pass insert would let the second rename overwrite the
        // first.
        let mut map: HashMap<PathBuf, String> = HashMap::new();
        map.insert(PathBuf::from("a.rs"), "a-content\n".into());
        map.insert(PathBuf::from("b.rs"), "b-content\n".into());

        remap_rename_keys(
            &mut map,
            &[df_renamed("a.rs", "b.rs"), df_renamed("b.rs", "a.rs")],
        );

        assert_eq!(
            map.get(Path::new("a.rs")).map(String::as_str),
            Some("b-content\n"),
            "a.rs now holds b's original content"
        );
        assert_eq!(
            map.get(Path::new("b.rs")).map(String::as_str),
            Some("a-content\n"),
            "b.rs now holds a's original content"
        );
    }

    #[test]
    fn remap_rename_keys_resolves_transitive_rename_chain() {
        // Regression (H6.19): a diff containing both `A→B` and `B→C`
        // must land A's snapshot at the terminal destination C. The
        // pre-H6.19 single-hop implementation staged A at B (which is
        // itself a rename source with no destination in `self.files`
        // post-extraction), so A's content was lost even though C had
        // no existing snapshot to preserve.
        let mut map: HashMap<PathBuf, String> = HashMap::new();
        map.insert(PathBuf::from("a.rs"), "a-content\n".into());
        map.insert(PathBuf::from("b.rs"), "b-content\n".into());

        remap_rename_keys(
            &mut map,
            &[df_renamed("a.rs", "b.rs"), df_renamed("b.rs", "c.rs")],
        );

        assert!(!map.contains_key(Path::new("a.rs")), "a consumed");
        assert!(
            !map.contains_key(Path::new("b.rs")),
            "b consumed as intermediate"
        );
        // Both A's and B's pre-rescan snapshots land at C. `.or_insert`
        // keeps whichever settles first; the other is dropped because
        // both describe content at paths that no longer exist.
        let at_c = map.get(Path::new("c.rs")).expect("c is terminal");
        assert!(
            at_c == "a-content\n" || at_c == "b-content\n",
            "c holds one of the chain-collapsed snapshots, got {at_c:?}"
        );
    }

    #[test]
    fn remap_rename_keys_skips_missing_snapshot() {
        let mut map: HashMap<PathBuf, String> = HashMap::new();
        map.insert(PathBuf::from("untouched.rs"), "z\n".into());
        remap_rename_keys(&mut map, &[df_renamed("missing.rs", "also-missing.rs")]);
        assert!(map.contains_key(Path::new("untouched.rs")));
        assert!(!map.contains_key(Path::new("also-missing.rs")));
    }

    #[test]
    fn remap_path_returns_new_path_for_rename() {
        let diff = [df_renamed("old.rs", "new.rs"), df_modified("other.rs")];
        assert_eq!(
            remap_path(Path::new("old.rs"), &diff).as_deref(),
            Some(Path::new("new.rs"))
        );
        assert_eq!(remap_path(Path::new("other.rs"), &diff), None);
        assert_eq!(remap_path(Path::new("absent.rs"), &diff), None);
    }

    #[test]
    fn remap_path_walks_transitive_rename_chain_to_terminal() {
        // Regression (post-H9 crew review): a cursor on `A` in an
        // `A→B, B→C` diff must land on the terminal destination `C`,
        // matching the landing that `apply_diff_files` and
        // `remap_rename_keys` produce. Before the fix, remap_path only
        // looked up direct `old_path` matches, so the cursor landed on
        // the stale intermediate `B`.
        let diff = [df_renamed("a.rs", "b.rs"), df_renamed("b.rs", "c.rs")];
        assert_eq!(
            remap_path(Path::new("a.rs"), &diff).as_deref(),
            Some(Path::new("c.rs")),
            "A walks through to terminal C, not intermediate B"
        );
        assert_eq!(
            remap_path(Path::new("b.rs"), &diff).as_deref(),
            Some(Path::new("c.rs")),
            "B walks directly to terminal C"
        );
    }

    #[test]
    fn remap_path_breaks_on_swap_cycle() {
        // Companion to `resolve_terminal_dest`'s swap handling: for
        // `A→B, B→A`, remap_path(A) = B and remap_path(B) = A, matching
        // the swap semantics the rest of the pipeline uses.
        let diff = [df_renamed("a.rs", "b.rs"), df_renamed("b.rs", "a.rs")];
        assert_eq!(
            remap_path(Path::new("a.rs"), &diff).as_deref(),
            Some(Path::new("b.rs"))
        );
        assert_eq!(
            remap_path(Path::new("b.rs"), &diff).as_deref(),
            Some(Path::new("a.rs"))
        );
    }

    // Regression test for H6.11: when a file is renamed between rescans
    // AND its content shifts, remap_rename_keys + apply_diff_files +
    // reanchor_comments must follow the content — the comment on "line
    // two" at old-line-2 should end up at new-line-3 after an insertion
    // above it, under the new path, not orphaned and not stuck at line 2.
    #[test]
    fn rename_reanchor_follows_content_through_line_shift() {
        let mut session = ReviewSession::new(
            PathBuf::from("/repo"),
            "abc123".to_string(),
            None,
            crate::model::review::SessionDiffSource::WorkingTree,
        );
        let old_path = PathBuf::from("src/old.rs");
        let new_path = PathBuf::from("src/new.rs");

        let old_content = "line one\nline two\nline three\n";
        let new_content = "inserted\nline one\nline two\nline three\n";

        // Pre-rescan: file at `old_path` with a comment on line 2
        // ("line two").
        session.add_file(old_path.clone(), FileStatus::Modified);
        let file = session.get_file_mut(&old_path).unwrap();
        let mut comment = Comment::new("hi".into(), CommentType::Note, None);
        comment.side = Some(LineSide::New);
        file.add_line_comment(2, comment);

        // `old_new_content` keyed by OLD path, as the TUI builds it from
        // the pre-rescan `self.diff_files`.
        let mut old_new_content: HashMap<PathBuf, String> = HashMap::new();
        old_new_content.insert(old_path.clone(), old_content.into());

        // Rescan reports rename + insertion at top.
        let diff_files = vec![df_renamed("src/old.rs", "src/new.rs")];

        // Apply the H6.11 fix: remap keys BEFORE apply_diff_files migrates
        // session keys, then reanchor.
        remap_rename_keys(&mut old_new_content, &diff_files);
        session.apply_diff_files(&diff_files);

        let mut new_content_map: HashMap<PathBuf, String> = HashMap::new();
        new_content_map.insert(new_path.clone(), new_content.into());
        let mut new_paths: HashSet<PathBuf> = HashSet::new();
        new_paths.insert(new_path.clone());

        reanchor_comments(
            &mut session,
            &old_new_content,
            &new_content_map,
            &new_paths,
            false,
        );

        // Post-rescan: comment moved to line 3 under the new path.
        assert!(!session.files.contains_key(&old_path));
        let migrated = session
            .files
            .get(&new_path)
            .expect("file should live at new path after rename");
        assert!(
            !migrated.line_comments.contains_key(&2),
            "comment should not remain at old line 2"
        );
        assert_eq!(
            migrated.line_comments.get(&3).map(Vec::len),
            Some(1),
            "comment should follow 'line two' to new line 3 after insertion above"
        );
        assert!(
            migrated.orphaned_comments.is_empty(),
            "no comments should be orphaned — content survived, just shifted"
        );
    }

    // Companion regression: without remap_rename_keys, re-anchoring on a
    // rename + line-shift fails to relocate surviving comments — they stay
    // at their stale pre-rescan line number, effectively pointing at the
    // wrong content. The positive test above proves the H6.11 fix moves
    // them correctly; this test proves the fix is load-bearing.
    #[test]
    fn without_remap_rename_keeps_comments_at_stale_line_number() {
        let mut session = ReviewSession::new(
            PathBuf::from("/repo"),
            "abc123".to_string(),
            None,
            crate::model::review::SessionDiffSource::WorkingTree,
        );
        let old_path = PathBuf::from("src/old.rs");
        let new_path = PathBuf::from("src/new.rs");
        let old_content = "line one\nline two\nline three\n";
        let new_content = "inserted\nline one\nline two\nline three\n";

        session.add_file(old_path.clone(), FileStatus::Modified);
        let file = session.get_file_mut(&old_path).unwrap();
        let mut comment = Comment::new("hi".into(), CommentType::Note, None);
        comment.side = Some(LineSide::New);
        file.add_line_comment(2, comment); // anchored on "line two"

        let mut old_new_content: HashMap<PathBuf, String> = HashMap::new();
        old_new_content.insert(old_path.clone(), old_content.into());

        let diff_files = vec![df_renamed("src/old.rs", "src/new.rs")];

        // Intentionally DO NOT call remap_rename_keys — this mimics the
        // pre-H6.11 broken flow.
        session.apply_diff_files(&diff_files);

        let mut new_content_map: HashMap<PathBuf, String> = HashMap::new();
        new_content_map.insert(new_path.clone(), new_content.into());
        let mut new_paths: HashSet<PathBuf> = HashSet::new();
        new_paths.insert(new_path.clone());

        reanchor_comments(
            &mut session,
            &old_new_content,
            &new_content_map,
            &new_paths,
            false,
        );

        // Pre-H6.11 bug: `old_new_content.get("src/new.rs")` misses (key is
        // still "src/old.rs"), falls back to `""`. `AnchorMap::from_content("",
        // new_content)` has an empty mapping, which short-circuits as
        // identity, so `reanchor_comments` skips re-keying. The comment
        // stays on its original line 2 — but line 2 in the NEW content is
        // "line one", not "line two". The comment now anchors the wrong
        // line.
        let migrated = session.files.get(&new_path).expect("rename migrated");
        assert_eq!(
            migrated.line_comments.get(&2).map(Vec::len),
            Some(1),
            "pre-H6.11: comment stuck on stale line number"
        );
        assert!(
            !migrated.line_comments.contains_key(&3),
            "pre-H6.11: comment did NOT move to the correct new line 3"
        );
    }

    // Regression: when a file disappears entirely from the rescan AND a
    // `LineSide::Old` (deletion-side) comment is orphaned, `last_seen_content`
    // must be empty — indexing `old_content` by a base-commit line number
    // returns unrelated new-side text, so we skip the lookup and rely on the
    // UI's empty-last-seen fallback.
    #[test]
    fn file_disappeared_orphan_with_old_side_has_empty_last_seen() {
        use crate::model::AnchorState;
        let mut session = ReviewSession::new(
            PathBuf::from("/repo"),
            "abc123".to_string(),
            None,
            crate::model::review::SessionDiffSource::WorkingTree,
        );
        let path = PathBuf::from("src/gone.rs");
        session.add_file(path.clone(), FileStatus::Modified);
        let file = session.get_file_mut(&path).unwrap();

        // Seed one old-side (deletion) and one new-side comment at the same
        // line number so we can observe both branches of the side switch.
        let mut old_side = Comment::new("old-side".into(), CommentType::Note, None);
        old_side.side = Some(LineSide::Old);
        let mut new_side = Comment::new("new-side".into(), CommentType::Note, None);
        new_side.side = Some(LineSide::New);
        file.add_line_comment(2, old_side);
        file.add_line_comment(2, new_side);

        // `old_new_content` is the pre-rescan NEW-side snapshot.
        let mut old_new_content: HashMap<PathBuf, String> = HashMap::new();
        old_new_content.insert(path.clone(), "keep1\nkeep2\nkeep3\n".into());
        // File disappeared entirely from the rescan.
        let new_paths: HashSet<PathBuf> = HashSet::new();
        let new_content_map: HashMap<PathBuf, String> = HashMap::new();

        reanchor_comments(
            &mut session,
            &old_new_content,
            &new_content_map,
            &new_paths,
            false,
        );

        let review = session.files.get(&path).unwrap();
        assert!(review.line_comments.is_empty(), "all comments orphaned");
        assert_eq!(review.orphaned_comments.len(), 2);

        // Partition orphans by side and check last_seen_content per side.
        let mut saw_old_empty = false;
        let mut saw_new_populated = false;
        for comment in &review.orphaned_comments {
            match comment.anchor.as_ref().unwrap() {
                AnchorState::Orphaned {
                    was_side,
                    last_seen_content,
                    ..
                } => {
                    if *was_side == LineSide::Old {
                        assert!(
                            last_seen_content.is_empty(),
                            "old-side orphan must have empty last_seen_content to avoid \
                             surfacing unrelated new-side text; got {last_seen_content:?}"
                        );
                        saw_old_empty = true;
                    } else {
                        assert_eq!(
                            last_seen_content, "keep2",
                            "new-side orphan pulls last_seen from the pre-rescan snapshot"
                        );
                        saw_new_populated = true;
                    }
                }
                other => panic!("expected Orphaned, got {other:?}"),
            }
        }
        assert!(saw_old_empty, "should see an old-side orphan");
        assert!(saw_new_populated, "should see a new-side orphan");
    }
}