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};
pub fn remap_rename_keys(old_new_content: &mut HashMap<PathBuf, String>, diff_files: &[DiffFile]) {
let direct = direct_rename_map(diff_files);
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));
}
}
for (new_path, content) in staged {
old_new_content.entry(new_path).or_insert(content);
}
}
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
}
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(¤t) {
if visited.contains(next) {
return current;
}
visited.insert(next.clone());
current = next.clone();
}
current
}
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))
}
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() {
stamp_anchors_from_keys(review);
if review.line_comments.is_empty() {
continue;
}
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);
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;
}
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);
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);
if side == LineSide::Old {
next.entry(line).or_default().push(comment);
continue;
}
match anchor_map.lookup(line) {
Some(new_line) => {
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;
}
}
fn comment_side(comment: &Comment, _fallback_line: u32) -> LineSide {
comment.side.unwrap_or(LineSide::New)
}
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() {
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() {
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() {
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"
);
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() {
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() {
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"))
);
}
#[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";
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);
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")];
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,
);
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"
);
}
#[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);
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")];
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,
);
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"
);
}
#[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();
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);
let mut old_new_content: HashMap<PathBuf, String> = HashMap::new();
old_new_content.insert(path.clone(), "keep1\nkeep2\nkeep3\n".into());
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);
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");
}
}