travelagent-core 1.10.3

Core library for travelagent code review tool
Documentation
//! Diff utilities shared across the workspace.
//!
//! - [`word_diff`] computes word-level highlight tokens for a pair of lines.
//! - [`pair_deletions_with_additions`] pairs adjacent deletion/addition runs
//!   inside a hunk so the renderer can ask for word-level highlights on the
//!   resulting "replace" pairs.

pub mod word_diff;

pub use word_diff::{Token, highlight_line_pair};

use crate::model::{DiffHunk, LineOrigin};

/// Pair consecutive deletions with the immediately following run of
/// additions.
///
/// Returns a vector of `(Option<usize>, Option<usize>)` pairs where each
/// `Some(idx)` is an index into `hunk.lines`. The left slot points at a
/// deletion line and the right slot at an addition line. Both slots are
/// filled only for "replace" runs where the deletion count equals the
/// following addition count; when the counts differ we bail on pairing
/// (returning `None` entries for every line so the caller can fall back to
/// whole-line highlighting). We never speculate about which deletion matches
/// which addition when the counts are unequal -- that's the job of a real
/// Myers-style diff, not a hunk-local heuristic.
///
/// Context lines, standalone additions, and standalone deletions produce
/// single-slot entries (`(Some(i), None)` or `(None, Some(i))`).
pub fn pair_deletions_with_additions(hunk: &DiffHunk) -> Vec<(Option<usize>, Option<usize>)> {
    let lines = &hunk.lines;
    let mut out: Vec<(Option<usize>, Option<usize>)> = Vec::with_capacity(lines.len());
    let mut i = 0;

    while i < lines.len() {
        match lines[i].origin {
            LineOrigin::Deletion => {
                // Collect the run of deletions.
                let del_start = i;
                let mut del_end = i + 1;
                while del_end < lines.len() && lines[del_end].origin == LineOrigin::Deletion {
                    del_end += 1;
                }

                // Collect the run of additions immediately following.
                let add_start = del_end;
                let mut add_end = add_start;
                while add_end < lines.len() && lines[add_end].origin == LineOrigin::Addition {
                    add_end += 1;
                }

                let del_count = del_end - del_start;
                let add_count = add_end - add_start;

                if del_count == add_count && del_count > 0 {
                    // Equal-count replace: pair index-by-index.
                    for off in 0..del_count {
                        out.push((Some(del_start + off), Some(add_start + off)));
                    }
                } else {
                    // Unequal counts: don't try to pair, emit single-slot
                    // entries so the caller falls back to whole-line color.
                    for off in 0..del_count {
                        out.push((Some(del_start + off), None));
                    }
                    for off in 0..add_count {
                        out.push((None, Some(add_start + off)));
                    }
                }

                i = add_end;
            }
            LineOrigin::Addition => {
                // Addition without a preceding deletion -> standalone.
                out.push((None, Some(i)));
                i += 1;
            }
            LineOrigin::Context => {
                out.push((Some(i), None));
                i += 1;
            }
        }
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model::{DiffHunk, DiffLine, LineOrigin};

    fn line(origin: LineOrigin, content: &str) -> DiffLine {
        DiffLine {
            origin,
            content: content.to_string(),
            old_lineno: None,
            new_lineno: None,
            highlighted_spans: None,
        }
    }

    fn hunk(lines: Vec<DiffLine>) -> DiffHunk {
        DiffHunk {
            header: String::new(),
            lines,
            old_start: 0,
            old_count: 0,
            new_start: 0,
            new_count: 0,
        }
    }

    #[test]
    fn pair_deletions_with_additions_pairs_adjacent_runs() {
        // 2 deletions followed by 2 additions -> 2 paired entries.
        let h = hunk(vec![
            line(LineOrigin::Context, "ctx"),
            line(LineOrigin::Deletion, "-a"),
            line(LineOrigin::Deletion, "-b"),
            line(LineOrigin::Addition, "+A"),
            line(LineOrigin::Addition, "+B"),
            line(LineOrigin::Context, "ctx2"),
        ]);
        let pairs = pair_deletions_with_additions(&h);
        assert_eq!(
            pairs,
            vec![
                (Some(0), None),    // context
                (Some(1), Some(3)), // -a paired with +A
                (Some(2), Some(4)), // -b paired with +B
                (Some(5), None),    // context
            ]
        );
    }

    #[test]
    fn pair_deletions_with_additions_bails_on_unequal_counts() {
        // 2 deletions + 1 addition -> no pairing; 3 single-slot entries.
        let h = hunk(vec![
            line(LineOrigin::Deletion, "-a"),
            line(LineOrigin::Deletion, "-b"),
            line(LineOrigin::Addition, "+A"),
        ]);
        let pairs = pair_deletions_with_additions(&h);
        assert_eq!(
            pairs,
            vec![(Some(0), None), (Some(1), None), (None, Some(2))]
        );
    }

    #[test]
    fn pair_deletions_with_additions_handles_standalone_additions() {
        let h = hunk(vec![
            line(LineOrigin::Addition, "+a"),
            line(LineOrigin::Context, "ctx"),
        ]);
        let pairs = pair_deletions_with_additions(&h);
        assert_eq!(pairs, vec![(None, Some(0)), (Some(1), None)]);
    }

    #[test]
    fn pair_deletions_with_additions_handles_deletion_without_following_addition() {
        // Deletion followed immediately by context (no addition after).
        let h = hunk(vec![
            line(LineOrigin::Deletion, "-a"),
            line(LineOrigin::Context, "ctx"),
        ]);
        let pairs = pair_deletions_with_additions(&h);
        // del_count=1, add_count=0 -> counts differ -> single-slot.
        assert_eq!(pairs, vec![(Some(0), None), (Some(1), None)]);
    }

    #[test]
    fn pair_deletions_with_additions_three_way_replace_preserves_order() {
        // 3 deletions + 3 additions wrapped in context must pair index-by-index
        // in order. Regression guard: a naive implementation could interleave
        // or reverse the pair list.
        let h = hunk(vec![
            line(LineOrigin::Context, "ctx-a"),
            line(LineOrigin::Deletion, "-1"),
            line(LineOrigin::Deletion, "-2"),
            line(LineOrigin::Deletion, "-3"),
            line(LineOrigin::Addition, "+1"),
            line(LineOrigin::Addition, "+2"),
            line(LineOrigin::Addition, "+3"),
            line(LineOrigin::Context, "ctx-b"),
        ]);
        let pairs = pair_deletions_with_additions(&h);
        assert_eq!(
            pairs,
            vec![
                (Some(0), None),    // ctx-a
                (Some(1), Some(4)), // -1 ↔ +1
                (Some(2), Some(5)), // -2 ↔ +2
                (Some(3), Some(6)), // -3 ↔ +3
                (Some(7), None),    // ctx-b
            ]
        );
    }

    #[test]
    fn pair_deletions_with_additions_empty_hunk() {
        let h = hunk(Vec::new());
        assert!(pair_deletions_with_additions(&h).is_empty());
    }
}