wsb 0.255.6379

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! A diff is what a reader agrees to. Every case here is one where assuming
//! "line N before is line N after" produces confident, wrong line numbers.

use super::*;

/// The lines a hunk shows, as `old|new|marker|text`, for readable assertions.
fn rendered(hunks: &[Hunk]) -> Vec<String> {
    hunks
        .iter()
        .flat_map(|hunk| &hunk.lines)
        .map(|line| match line {
            DiffLine::Context { old, new, text, .. } => format!("{old}|{new}| |{text}"),
            DiffLine::Removed { old, text, .. } => format!("{old}|-|-|{text}"),
            DiffLine::Added { new, text, .. } => format!("-|{new}|+|{text}"),
        })
        .collect()
}

#[test]
fn test11110_a_replacement_that_adds_lines_renumbers_everything_after_it() {
    // A substitute containing newlines splits one line into three. Every line
    // after it has a different number in the new file than in the old — the
    // case a line-for-line preview gets wrong and reports confidently.
    let before = "one\nTARGET\nthree\nfour\n";
    let after = "one\nA\nB\nC\nthree\nfour\n";

    let found = hunks(before, after, 1).expect("diffs");
    assert_eq!(
        rendered(&found),
        vec![
            "1|1| |one",
            "2|-|-|TARGET",
            "-|2|+|A",
            "-|3|+|B",
            "-|4|+|C",
            "3|5| |three",
        ]
    );
    // `three` is line 3 before and line 5 after. Both numbers are shown, and
    // neither is guessed.
}

#[test]
fn test11111_a_replacement_that_removes_lines_is_shown_as_removal() {
    // A regular expression matching across lines collapses several into one.
    let before = "keep\nfirst\nsecond\nthird\nkeep2\n";
    let after = "keep\nmerged\nkeep2\n";

    let found = hunks(before, after, 1).expect("diffs");
    let lines = rendered(&found);
    assert!(lines.contains(&"2|-|-|first".to_string()), "{lines:?}");
    assert!(lines.contains(&"3|-|-|second".to_string()), "{lines:?}");
    assert!(lines.contains(&"4|-|-|third".to_string()), "{lines:?}");
    assert!(lines.contains(&"-|2|+|merged".to_string()), "{lines:?}");
    // `keep2` is line 5 before, line 3 after.
    assert!(lines.contains(&"5|3| |keep2".to_string()), "{lines:?}");
}

#[test]
fn test11112_a_line_deleted_outright_is_not_paired_with_anything() {
    // Replacing a pattern with nothing, where the pattern is the whole line.
    let before = "a\nDROP ME\nb\n";
    let after = "a\nb\n";
    let lines = rendered(&hunks(before, after, 1).expect("diffs"));
    assert!(lines.contains(&"2|-|-|DROP ME".to_string()), "{lines:?}");
    assert!(
        !lines.iter().any(|l| l.contains("+|")),
        "nothing was added: {lines:?}"
    );
}

#[test]
fn test11113_only_the_changed_parts_are_shown_with_context_around_them() {
    // A thousand-line file with one change is not a thousand-line diff.
    let before: String = (1..=200).map(|n| format!("line {n}\n")).collect();
    let after = before.replace("line 100\n", "CHANGED\n");

    let found = hunks(&before, &after, 2).expect("diffs");
    assert_eq!(found.len(), 1, "one change is one hunk");
    // Two before, the pair, two after.
    assert_eq!(rendered(&found).len(), 2 + 2 + 2);
    assert_eq!(found[0].old_start, 98);
    assert_eq!(found[0].new_start, 98);
}

#[test]
fn test11114_changes_far_apart_are_separate_hunks_and_near_ones_are_joined() {
    let before: String = (1..=60).map(|n| format!("line {n}\n")).collect();
    let after = before
        .replace("line 10\n", "TEN\n")
        .replace("line 12\n", "TWELVE\n")
        .replace("line 50\n", "FIFTY\n");

    let found = hunks(&before, &after, 2).expect("diffs");
    // 10 and 12 are within each other's context and share a hunk; 50 is its
    // own. Splitting the first pair would print line 11 twice.
    assert_eq!(found.len(), 2, "{:?}", found.iter().map(|h| h.old_start).collect::<Vec<_>>());
    assert_eq!(found[0].old_start, 8);
    assert_eq!(found[1].old_start, 48);
}

#[test]
fn test11115_a_missing_trailing_newline_is_a_real_change() {
    // `str::lines` cannot tell these apart, and a refactor that ate the final
    // newline would show an empty diff.
    let found = hunks("a\nb\n", "a\nb", 1).expect("diffs");
    assert!(!found.is_empty(), "the change is visible");

    // And the reverse.
    let added = hunks("a\nb", "a\nb\n", 1).expect("diffs");
    assert!(!added.is_empty());
}

#[test]
fn test11116_identical_text_has_no_hunks() {
    assert!(hunks("same\ntext\n", "same\ntext\n", 2).expect("diffs").is_empty());
    assert!(hunks("", "", 2).expect("diffs").is_empty());
}

#[test]
fn test11117_a_file_replaced_wholesale_is_still_correct() {
    let before = "alpha\nbeta\ngamma\n";
    let after = "one\ntwo\nthree\nfour\n";
    let lines = rendered(&hunks(before, after, 2).expect("diffs"));
    // Every old line removed, every new line added, and the numbering on each
    // side runs independently.
    assert!(lines.contains(&"1|-|-|alpha".to_string()), "{lines:?}");
    assert!(lines.contains(&"3|-|-|gamma".to_string()), "{lines:?}");
    assert!(lines.contains(&"-|1|+|one".to_string()), "{lines:?}");
    assert!(lines.contains(&"-|4|+|four".to_string()), "{lines:?}");
}

#[test]
fn test11118_an_empty_file_gaining_content_is_all_additions() {
    let lines = rendered(&hunks("", "new\nlines\n", 2).expect("diffs"));
    assert_eq!(lines, vec!["-|1|+|new", "-|2|+|lines"]);

    let emptied = rendered(&hunks("gone\n", "", 2).expect("diffs"));
    assert_eq!(emptied, vec!["1|-|-|gone"]);
}

#[test]
fn test11119_the_edit_script_is_shortest_not_merely_correct() {
    // Removing everything and adding everything back is a CORRECT script and
    // a useless diff. One line changed in the middle must cost two edits, not
    // six — this is the difference between Myers and a naive answer.
    let before = "a\nb\nc\n";
    let after = "a\nCHANGED\nc\n";
    let lines = rendered(&hunks(before, after, 0).expect("diffs"));
    assert_eq!(lines, vec!["2|-|-|b", "-|2|+|CHANGED"]);
}

#[test]
fn test11120_a_diff_beyond_the_limit_is_refused_and_says_so() {
    // A refusal, not a silently cheaper wrong answer. The change still applies;
    // only this view is unavailable.
    let before: String = (0..MAX_EDITS).map(|n| format!("old {n}\n")).collect();
    let after: String = (0..MAX_EDITS).map(|n| format!("new {n}\n")).collect();
    let refused = hunks(&before, &after, 2).expect_err("past the limit");
    assert!(refused.contains("still applies"), "actionable: {refused}");
}