use similar::{ChangeTag, TextDiff};
use super::LineSource;
#[derive(Debug)]
pub struct InlineDiffResult {
pub spans: Vec<InlineSpan>,
pub is_meaningful: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineSpan {
pub text: String,
pub source: Option<LineSource>,
pub is_deletion: bool,
}
pub fn compute_inline_diff_merged(
old_line: &str,
new_line: &str,
change_source: LineSource,
) -> InlineDiffResult {
let deletion_source = match change_source {
LineSource::Committed => LineSource::DeletedBase,
LineSource::Staged => LineSource::DeletedCommitted,
LineSource::Unstaged => LineSource::DeletedStaged,
_ => LineSource::DeletedBase,
};
let diff = TextDiff::from_chars(old_line, new_line);
let mut spans = Vec::new();
let mut max_unchanged_segment = 0usize;
let mut num_unchanged_segments = 0usize;
let mut seen_non_whitespace = false;
let mut pending_unchanged = String::new();
let mut pending_deleted = String::new();
let mut pending_inserted = String::new();
let flush_unchanged = |pending: &mut String,
spans: &mut Vec<InlineSpan>,
seen_non_whitespace: &mut bool,
max_unchanged_segment: &mut usize,
num_unchanged_segments: &mut usize| {
if pending.is_empty() {
return;
}
let has_non_ws = pending.chars().any(|c| !c.is_whitespace());
if *seen_non_whitespace || has_non_ws {
let segment_len = pending.chars().count();
*max_unchanged_segment = (*max_unchanged_segment).max(segment_len);
*num_unchanged_segments += 1;
}
if has_non_ws {
*seen_non_whitespace = true;
}
spans.push(InlineSpan {
text: std::mem::take(pending),
source: None,
is_deletion: false,
});
};
let flush_changed = |pending: &mut String,
spans: &mut Vec<InlineSpan>,
seen_non_whitespace: &mut bool,
source: LineSource,
is_deletion: bool| {
if pending.is_empty() {
return;
}
if pending.chars().any(|c| !c.is_whitespace()) {
*seen_non_whitespace = true;
}
spans.push(InlineSpan {
text: std::mem::take(pending),
source: Some(source),
is_deletion,
});
};
for change in diff.iter_all_changes() {
let text = change.value();
match change.tag() {
ChangeTag::Equal => {
if !pending_deleted.is_empty() || !pending_inserted.is_empty() {
flush_unchanged(
&mut pending_unchanged,
&mut spans,
&mut seen_non_whitespace,
&mut max_unchanged_segment,
&mut num_unchanged_segments,
);
flush_changed(
&mut pending_deleted,
&mut spans,
&mut seen_non_whitespace,
deletion_source,
true,
);
flush_changed(
&mut pending_inserted,
&mut spans,
&mut seen_non_whitespace,
change_source,
false,
);
}
pending_unchanged.push_str(text);
}
ChangeTag::Delete => {
pending_deleted.push_str(text);
}
ChangeTag::Insert => {
pending_inserted.push_str(text);
}
}
}
flush_unchanged(
&mut pending_unchanged,
&mut spans,
&mut seen_non_whitespace,
&mut max_unchanged_segment,
&mut num_unchanged_segments,
);
flush_changed(
&mut pending_deleted,
&mut spans,
&mut seen_non_whitespace,
deletion_source,
true,
);
flush_changed(
&mut pending_inserted,
&mut spans,
&mut seen_non_whitespace,
change_source,
false,
);
let has_meaningful_segment = max_unchanged_segment >= 5;
let not_too_fragmented = num_unchanged_segments <= 4;
let is_meaningful = has_meaningful_segment && not_too_fragmented;
InlineDiffResult { spans, is_meaningful }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inline_span_unchanged() {
let span = InlineSpan {
text: "unchanged".to_string(),
source: None,
is_deletion: false,
};
assert_eq!(span.text, "unchanged");
assert!(span.source.is_none());
assert!(!span.is_deletion);
}
#[test]
fn test_inline_span_deletion() {
let span = InlineSpan {
text: "deleted".to_string(),
source: Some(LineSource::DeletedBase),
is_deletion: true,
};
assert_eq!(span.text, "deleted");
assert_eq!(span.source, Some(LineSource::DeletedBase));
assert!(span.is_deletion);
}
#[test]
fn test_inline_span_insertion() {
let span = InlineSpan {
text: "inserted".to_string(),
source: Some(LineSource::Committed),
is_deletion: false,
};
assert_eq!(span.text, "inserted");
assert_eq!(span.source, Some(LineSource::Committed));
assert!(!span.is_deletion);
}
#[test]
fn test_deletion_source_for_committed_change() {
let result = compute_inline_diff_merged("old content", "new content", LineSource::Committed);
let deletion_spans: Vec<_> = result.spans.iter()
.filter(|s| s.is_deletion)
.collect();
for span in deletion_spans {
assert_eq!(span.source, Some(LineSource::DeletedBase));
}
}
#[test]
fn test_deletion_source_for_staged_change() {
let result = compute_inline_diff_merged("old content", "new content", LineSource::Staged);
let deletion_spans: Vec<_> = result.spans.iter()
.filter(|s| s.is_deletion)
.collect();
for span in deletion_spans {
assert_eq!(span.source, Some(LineSource::DeletedCommitted));
}
}
#[test]
fn test_deletion_source_for_unstaged_change() {
let result = compute_inline_diff_merged("old content", "new content", LineSource::Unstaged);
let deletion_spans: Vec<_> = result.spans.iter()
.filter(|s| s.is_deletion)
.collect();
for span in deletion_spans {
assert_eq!(span.source, Some(LineSource::DeletedStaged));
}
}
#[test]
fn test_meaningful_when_long_unchanged_segment() {
let result = compute_inline_diff_merged("hello world", "hello earth", LineSource::Committed);
assert!(result.is_meaningful, "Should be meaningful with 6-char unchanged prefix");
}
#[test]
fn test_meaningful_suffix_preservation() {
let result = compute_inline_diff_merged(
"do_thing(data)",
"do_thing(data, params)",
LineSource::Committed,
);
assert!(result.is_meaningful, "Should be meaningful - prefix/suffix unchanged");
}
#[test]
fn test_not_meaningful_too_short_unchanged() {
let result = compute_inline_diff_merged("abc", "xyz", LineSource::Committed);
assert!(!result.is_meaningful, "Should not be meaningful - no shared content");
}
#[test]
fn test_not_meaningful_only_short_matches() {
let result = compute_inline_diff_merged("end", "let", LineSource::Committed);
assert!(!result.is_meaningful, "Should not be meaningful - only 1 char match");
}
#[test]
fn test_not_meaningful_too_fragmented() {
let result = compute_inline_diff_merged(
"for i in (x..y).rev() {",
"// the range span note",
LineSource::Committed,
);
assert!(!result.is_meaningful, "Should not be meaningful - too fragmented");
}
#[test]
fn test_leading_whitespace_not_counted_for_meaningfulness() {
let result = compute_inline_diff_merged(
" }",
" .map(|x| x)",
LineSource::Committed,
);
assert!(!result.is_meaningful, "Leading whitespace alone shouldn't make diff meaningful");
}
#[test]
fn test_identical_lines_single_unchanged_span() {
let result = compute_inline_diff_merged("same line", "same line", LineSource::Committed);
assert_eq!(result.spans.len(), 1);
assert_eq!(result.spans[0].text, "same line");
assert!(result.spans[0].source.is_none());
assert!(!result.spans[0].is_deletion);
}
#[test]
fn test_empty_lines() {
let result = compute_inline_diff_merged("", "", LineSource::Committed);
assert!(result.spans.is_empty());
assert!(!result.is_meaningful);
}
#[test]
fn test_simple_replacement_structure() {
let result = compute_inline_diff_merged(
"commercial_renewal.name",
"bond.name",
LineSource::Committed,
);
let has_deletions = result.spans.iter().any(|s| s.is_deletion);
let has_insertions = result.spans.iter().any(|s| s.source == Some(LineSource::Committed));
let has_unchanged = result.spans.iter().any(|s| s.source.is_none());
assert!(has_deletions, "Should have deletion spans");
assert!(has_insertions, "Should have insertion spans");
assert!(has_unchanged, "Should have unchanged spans");
let unchanged: String = result.spans.iter()
.filter(|s| s.source.is_none())
.map(|s| s.text.as_str())
.collect();
assert!(unchanged.contains(".name"), "Should preserve '.name' as unchanged");
let deleted: String = result.spans.iter()
.filter(|s| s.is_deletion)
.map(|s| s.text.as_str())
.collect();
assert!(!deleted.is_empty(), "Should have deleted content");
}
#[test]
fn test_prefix_change() {
let result = compute_inline_diff_merged(
"old_function_name()",
"new_function_name()",
LineSource::Committed,
);
assert!(result.is_meaningful, "Should be meaningful - long unchanged suffix");
let unchanged: String = result.spans.iter()
.filter(|s| s.source.is_none())
.map(|s| s.text.as_str())
.collect();
assert!(unchanged.contains("_function_name()"));
}
#[test]
fn test_suffix_change() {
let result = compute_inline_diff_merged(
"function_name_old()",
"function_name_new()",
LineSource::Committed,
);
assert!(result.is_meaningful, "Should be meaningful - long unchanged prefix");
let unchanged: String = result.spans.iter()
.filter(|s| s.source.is_none())
.map(|s| s.text.as_str())
.collect();
assert!(unchanged.contains("function_name_"));
}
#[test]
fn test_middle_insertion() {
let result = compute_inline_diff_merged(
"hello world",
"hello beautiful world",
LineSource::Staged,
);
let unchanged_texts: Vec<_> = result.spans.iter()
.filter(|s| s.source.is_none())
.map(|s| s.text.as_str())
.collect();
let inserted_texts: Vec<_> = result.spans.iter()
.filter(|s| s.source == Some(LineSource::Staged))
.map(|s| s.text.as_str())
.collect();
assert!(unchanged_texts.contains(&"hello "));
assert!(unchanged_texts.contains(&"world"));
assert!(inserted_texts.iter().any(|t| t.contains("beautiful")));
}
#[test]
fn test_complete_replacement_not_meaningful() {
let result = compute_inline_diff_merged(
"func foo() { return 42; }",
"struct Bar { x: i32, y: i32 }",
LineSource::Committed,
);
assert!(!result.is_meaningful, "Completely different lines should not be meaningful");
}
#[test]
fn test_single_char_difference() {
let result = compute_inline_diff_merged("test_a", "test_b", LineSource::Committed);
assert!(result.is_meaningful, "5 char unchanged segment should be meaningful");
}
#[test]
fn test_whitespace_only_change() {
let result = compute_inline_diff_merged(
"let x = 1;",
"let x = 1;",
LineSource::Unstaged,
);
let has_insertion = result.spans.iter().any(|s| s.source.is_some() && !s.is_deletion);
assert!(has_insertion, "Should detect whitespace insertions");
}
#[test]
fn test_unicode_content() {
let result = compute_inline_diff_merged(
"hello こんにちは world",
"hello 你好 world",
LineSource::Committed,
);
let unchanged: String = result.spans.iter()
.filter(|s| s.source.is_none())
.map(|s| s.text.as_str())
.collect();
assert!(unchanged.contains("hello "));
assert!(unchanged.contains(" world"));
}
#[test]
fn test_deletion_before_insertion_ordering() {
let result = compute_inline_diff_merged("old", "new", LineSource::Committed);
let deletion_pos = result.spans.iter().position(|s| s.is_deletion);
let insertion_pos = result.spans.iter().position(|s| !s.is_deletion && s.source.is_some());
if let (Some(del), Some(ins)) = (deletion_pos, insertion_pos) {
assert!(del < ins, "Deletion should come before insertion");
}
}
#[test]
fn test_multiple_changes_in_line() {
let result = compute_inline_diff_merged(
"let x = foo(a, b);",
"let y = bar(c, d);",
LineSource::Committed,
);
let unchanged_count = result.spans.iter()
.filter(|s| s.source.is_none())
.count();
assert!(unchanged_count >= 2, "Should have multiple unchanged segments");
}
}