travelagent-core 1.10.3

Core library for travelagent code review tool
Documentation
//! Line-number re-anchoring for live review rescans.
//!
//! After the working tree changes, the diff's "new side" line numbers shift.
//! `AnchorMap` captures `old_line -> Option<new_line>` for every line in the
//! previous version of a file; `None` means the line was deleted.
//!
//! The implementation uses the `similar` crate's line-level `TextDiff` — the
//! same approach git uses internally for line-level diffs. It is
//! content-based, not patch-based, which keeps it robust against reformatting
//! and whitespace churn.

use similar::{ChangeTag, TextDiff};

/// Maps old (pre-rescan) line numbers to new line numbers for a single file.
/// `None` in the map means the line disappeared (delete).
#[derive(Debug, Clone)]
pub struct AnchorMap {
    /// 1-indexed: `mapping[i-1]` is the new line number for old line `i`, or
    /// `None` if old line `i` was deleted.
    mapping: Vec<Option<u32>>,
}

impl AnchorMap {
    /// Build a map by diffing `old` against `new` line-by-line. Lines are
    /// 1-indexed in the public API (matching how every other line-number
    /// field in the crate is stored).
    #[must_use]
    pub fn from_content(old: &str, new: &str) -> Self {
        let diff = TextDiff::from_lines(old, new);
        let old_line_count = if old.is_empty() {
            0
        } else {
            // Count newlines + (1 if the final line has no trailing newline).
            // `similar` itself groups by \n, so matching that heuristic keeps
            // the mapping's length in lock-step with the number of old lines
            // the diff emits.
            count_lines(old)
        };

        let mut mapping: Vec<Option<u32>> = vec![None; old_line_count];
        let mut old_ln: u32 = 1;
        let mut new_ln: u32 = 1;

        for change in diff.iter_all_changes() {
            match change.tag() {
                ChangeTag::Equal => {
                    if let Some(slot) = mapping.get_mut((old_ln as usize).saturating_sub(1)) {
                        *slot = Some(new_ln);
                    }
                    old_ln = old_ln.saturating_add(1);
                    new_ln = new_ln.saturating_add(1);
                }
                ChangeTag::Delete => {
                    if let Some(slot) = mapping.get_mut((old_ln as usize).saturating_sub(1)) {
                        *slot = None;
                    }
                    old_ln = old_ln.saturating_add(1);
                }
                ChangeTag::Insert => {
                    new_ln = new_ln.saturating_add(1);
                }
            }
        }

        Self { mapping }
    }

    /// Return the new line number for `old_line`, or `None` if that line was
    /// deleted (or `old_line` is out of range — e.g., past the end of the old
    /// content).
    #[must_use]
    pub fn lookup(&self, old_line: u32) -> Option<u32> {
        if old_line == 0 {
            return None;
        }
        self.mapping.get((old_line as usize) - 1).copied().flatten()
    }

    /// True when every old line maps 1:1 to the same new line. Useful as a
    /// short-circuit so callers can skip re-keying the comment map when
    /// nothing moved.
    #[must_use]
    pub fn is_identity(&self) -> bool {
        self.mapping
            .iter()
            .enumerate()
            .all(|(i, slot)| *slot == Some((i as u32) + 1))
    }
}

/// Count the lines in `s` the same way `similar` groups them: lines are
/// separated by `\n`, and a non-empty trailing chunk without a terminator
/// still counts as a line.
fn count_lines(s: &str) -> usize {
    if s.is_empty() {
        return 0;
    }
    let mut n = s.matches('\n').count();
    if !s.ends_with('\n') {
        n += 1;
    }
    n
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn identity_when_content_unchanged() {
        let text = "one\ntwo\nthree\n";
        let map = AnchorMap::from_content(text, text);
        assert!(map.is_identity());
        assert_eq!(map.lookup(1), Some(1));
        assert_eq!(map.lookup(2), Some(2));
        assert_eq!(map.lookup(3), Some(3));
    }

    #[test]
    fn shift_down_when_line_inserted_above() {
        let old = "alpha\nbeta\ngamma\n";
        let new = "zero\nalpha\nbeta\ngamma\n";
        let map = AnchorMap::from_content(old, new);
        assert!(!map.is_identity());
        assert_eq!(map.lookup(1), Some(2), "alpha shifted from 1 -> 2");
        assert_eq!(map.lookup(2), Some(3), "beta shifted from 2 -> 3");
        assert_eq!(map.lookup(3), Some(4), "gamma shifted from 3 -> 4");
    }

    #[test]
    fn shift_up_when_line_deleted_above() {
        let old = "zero\nalpha\nbeta\ngamma\n";
        let new = "alpha\nbeta\ngamma\n";
        let map = AnchorMap::from_content(old, new);
        assert_eq!(map.lookup(1), None, "zero was deleted");
        assert_eq!(map.lookup(2), Some(1));
        assert_eq!(map.lookup(3), Some(2));
        assert_eq!(map.lookup(4), Some(3));
    }

    #[test]
    fn orphan_when_anchored_line_itself_deleted() {
        let old = "keep\nremove me\nkeep-too\n";
        let new = "keep\nkeep-too\n";
        let map = AnchorMap::from_content(old, new);
        assert_eq!(map.lookup(1), Some(1));
        assert_eq!(map.lookup(2), None, "'remove me' was deleted");
        assert_eq!(map.lookup(3), Some(2));
    }

    #[test]
    fn orphan_with_last_seen_content_preserved() {
        // Simulated caller that uses lookup() to drive orphaning.
        let old = "alpha\nbeta\ngamma\n";
        let new = "alpha\ngamma\n";
        let old_lines: Vec<&str> = old.lines().collect();
        let map = AnchorMap::from_content(old, new);

        // "Anchor" comments at old lines 1..=3, record what they saw.
        struct FakeComment {
            old_line: u32,
            last_seen: String,
            new_line: Option<u32>,
        }
        let mut comments: Vec<FakeComment> = (1..=3u32)
            .map(|ln| FakeComment {
                old_line: ln,
                last_seen: old_lines[(ln - 1) as usize].to_string(),
                new_line: None,
            })
            .collect();

        for c in &mut comments {
            c.new_line = map.lookup(c.old_line);
        }

        assert_eq!(comments[0].new_line, Some(1)); // alpha
        assert_eq!(
            comments[1].new_line, None,
            "beta was deleted, comment 1 should orphan"
        );
        assert_eq!(
            comments[1].last_seen, "beta",
            "orphaned comment remembers its source line"
        );
        assert_eq!(comments[2].new_line, Some(2)); // gamma shifted
    }

    #[test]
    fn empty_new_file_orphans_everything() {
        let old = "alpha\nbeta\ngamma\n";
        let new = "";
        let map = AnchorMap::from_content(old, new);
        for ln in 1..=3u32 {
            assert_eq!(map.lookup(ln), None, "line {ln} should orphan");
        }
    }

    #[test]
    fn empty_old_file_has_nothing_to_anchor() {
        let old = "";
        let new = "alpha\nbeta\n";
        let map = AnchorMap::from_content(old, new);
        // No old lines -> no mapping entries.
        assert_eq!(map.lookup(1), None);
        assert!(map.is_identity(), "empty mapping is vacuously identity");
    }

    #[test]
    fn identical_nonempty_content_is_identity() {
        let text = "hello world\n";
        let map = AnchorMap::from_content(text, text);
        assert!(map.is_identity());
        assert_eq!(map.lookup(1), Some(1));
    }

    #[test]
    fn lookup_past_end_returns_none() {
        let old = "alpha\nbeta\n";
        let new = "alpha\nbeta\n";
        let map = AnchorMap::from_content(old, new);
        assert_eq!(map.lookup(0), None);
        assert_eq!(map.lookup(99), None);
    }

    #[test]
    fn insert_then_delete_keeps_surviving_lines_aligned() {
        // Replace line 2 in-place: delete "old-middle", insert "new-middle".
        let old = "top\nold-middle\nbottom\n";
        let new = "top\nnew-middle\nbottom\n";
        let map = AnchorMap::from_content(old, new);
        assert_eq!(map.lookup(1), Some(1));
        assert_eq!(
            map.lookup(2),
            None,
            "old-middle is treated as deleted, comment should orphan"
        );
        assert_eq!(map.lookup(3), Some(3));
    }
}