use similar::{ChangeTag, TextDiff};
pub const LONG_TEXT_THRESHOLD: usize = 120;
const SENTENCE_SPLIT_THRESHOLD: usize = 120;
const WORD_WRAP_WIDTH: usize = 80;
pub const CONTEXT_TRUNCATE_LEN: usize = 80;
#[derive(Debug, Clone, PartialEq)]
pub struct WordSegment {
pub text: String,
pub kind: WordSegmentKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WordSegmentKind {
Equal,
Changed,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiffLine {
Equal(String),
Delete(String),
Insert(String),
Modified {
old_segments: Vec<WordSegment>,
new_segments: Vec<WordSegment>,
},
}
#[derive(Debug, Clone)]
pub struct TextDiffHunk {
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone)]
pub struct TextDiffResult {
pub hunks: Vec<TextDiffHunk>,
pub deletions: usize,
pub insertions: usize,
}
pub fn is_long_text(s: &str) -> bool {
s.len() >= LONG_TEXT_THRESHOLD || s.contains('\n')
}
pub fn diff_text(old: &str, new: &str) -> TextDiffResult {
let diff = TextDiff::from_lines(old, new);
let mut deletions = 0;
let mut insertions = 0;
let mut hunks = Vec::new();
for group in diff.grouped_ops(1) {
let mut lines = Vec::new();
for op in &group {
for change in diff.iter_changes(op) {
let text = change.value().to_string();
match change.tag() {
ChangeTag::Equal => lines.push(DiffLine::Equal(text)),
ChangeTag::Delete => {
deletions += 1;
lines.push(DiffLine::Delete(text));
}
ChangeTag::Insert => {
insertions += 1;
lines.push(DiffLine::Insert(text));
}
}
}
}
if !lines.is_empty() {
lines = pair_modifications(lines);
hunks.push(TextDiffHunk { lines });
}
}
TextDiffResult {
hunks,
deletions,
insertions,
}
}
fn pair_modifications(lines: Vec<DiffLine>) -> Vec<DiffLine> {
let mut result = Vec::with_capacity(lines.len());
let mut i = 0;
while i < lines.len() {
if matches!(&lines[i], DiffLine::Delete(_)) {
let delete_start = i;
while i < lines.len() && matches!(&lines[i], DiffLine::Delete(_)) {
i += 1;
}
let delete_end = i;
let insert_start = i;
while i < lines.len() && matches!(&lines[i], DiffLine::Insert(_)) {
i += 1;
}
let insert_end = i;
let delete_count = delete_end - delete_start;
let insert_count = insert_end - insert_start;
let pair_count = delete_count.min(insert_count);
for j in 0..pair_count {
let old_text = match &lines[delete_start + j] {
DiffLine::Delete(s) => s.clone(),
_ => unreachable!(),
};
let new_text = match &lines[insert_start + j] {
DiffLine::Insert(s) => s.clone(),
_ => unreachable!(),
};
let (old_segments, new_segments) = diff_words(&old_text, &new_text);
result.push(DiffLine::Modified {
old_segments,
new_segments,
});
}
for j in pair_count..delete_count {
result.push(lines[delete_start + j].clone());
}
for j in pair_count..insert_count {
result.push(lines[insert_start + j].clone());
}
} else {
result.push(lines[i].clone());
i += 1;
}
}
result
}
pub fn diff_words(old: &str, new: &str) -> (Vec<WordSegment>, Vec<WordSegment>) {
let diff = TextDiff::from_words(old, new);
let mut old_segments = Vec::new();
let mut new_segments = Vec::new();
for change in diff.iter_all_changes() {
let text = change.value().to_string();
match change.tag() {
ChangeTag::Equal => {
old_segments.push(WordSegment {
text: text.clone(),
kind: WordSegmentKind::Equal,
});
new_segments.push(WordSegment {
text,
kind: WordSegmentKind::Equal,
});
}
ChangeTag::Delete => {
old_segments.push(WordSegment {
text,
kind: WordSegmentKind::Changed,
});
}
ChangeTag::Insert => {
new_segments.push(WordSegment {
text,
kind: WordSegmentKind::Changed,
});
}
}
}
(old_segments, new_segments)
}
pub fn truncate_context(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
return s.to_string();
}
let ellipsis = " ... ";
let ellipsis_len = ellipsis.len();
if max_len <= ellipsis_len + 4 {
return s.chars().take(max_len).collect();
}
let half = (max_len - ellipsis_len) / 2;
let prefix_end = s
.char_indices()
.nth(half)
.map(|(i, _)| i)
.unwrap_or(s.len());
let total_chars = s.chars().count();
let suffix_start_char = total_chars.saturating_sub(half);
let suffix_start = s
.char_indices()
.nth(suffix_start_char)
.map(|(i, _)| i)
.unwrap_or(s.len());
format!("{}{}{}", &s[..prefix_end], ellipsis, &s[suffix_start..])
}
pub fn normalize_for_diff(text: &str) -> String {
let mut result = String::new();
let parts: Vec<&str> = text.split('\n').collect();
let line_count = parts.len();
for (idx, line) in parts.into_iter().enumerate() {
if line.is_empty() && idx == line_count - 1 {
break;
}
if line.len() <= SENTENCE_SPLIT_THRESHOLD {
result.push_str(line);
result.push('\n');
} else {
let sentences = split_at_sentence_boundaries(line);
if sentences.len() <= 1 {
let wrapped = word_wrap(line.trim(), WORD_WRAP_WIDTH);
for wrap_line in &wrapped {
result.push_str(wrap_line);
result.push('\n');
}
} else {
for sentence in &sentences {
let trimmed = sentence.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.len() > SENTENCE_SPLIT_THRESHOLD {
let wrapped = word_wrap(trimmed, WORD_WRAP_WIDTH);
for wrap_line in &wrapped {
result.push_str(wrap_line);
result.push('\n');
}
} else {
result.push_str(trimmed);
result.push('\n');
}
}
}
}
}
result
}
fn split_at_sentence_boundaries(text: &str) -> Vec<&str> {
let bytes = text.as_bytes();
let mut result = Vec::new();
let mut start = 0;
let mut i = 0;
while i + 2 < bytes.len() {
if (bytes[i] == b'.' || bytes[i] == b'?' || bytes[i] == b'!')
&& bytes[i + 1] == b' '
&& bytes[i + 2].is_ascii_uppercase()
{
result.push(&text[start..=i]);
start = i + 2;
i = start;
} else {
i += 1;
}
}
if start < text.len() {
result.push(&text[start..]);
}
result
}
fn word_wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split(' ') {
if word.is_empty() {
continue;
}
if current.is_empty() {
current.push_str(word);
} else if current.len() + 1 + word.len() > width {
lines.push(current);
current = word.to_string();
} else {
current.push(' ');
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
lines
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_long_text_short() {
assert!(!is_long_text("hello"));
}
#[test]
fn test_is_long_text_by_length() {
let s = "a".repeat(LONG_TEXT_THRESHOLD);
assert!(is_long_text(&s));
}
#[test]
fn test_is_long_text_multiline() {
assert!(is_long_text("line one\nline two"));
}
#[test]
fn test_diff_identical() {
let result = diff_text("hello\n", "hello\n");
assert_eq!(result.deletions, 0);
assert_eq!(result.insertions, 0);
assert!(result.hunks.is_empty());
}
#[test]
fn test_diff_single_line_change() {
let old = "line one\nline two\nline three\n";
let new = "line one\nline TWO\nline three\n";
let result = diff_text(old, new);
assert_eq!(result.deletions, 1);
assert_eq!(result.insertions, 1);
assert_eq!(result.hunks.len(), 1);
let lines = &result.hunks[0].lines;
assert!(lines.iter().any(|l| matches!(l, DiffLine::Modified { .. })));
}
#[test]
fn test_diff_addition() {
let old = "line one\nline three\n";
let new = "line one\nline two\nline three\n";
let result = diff_text(old, new);
assert_eq!(result.deletions, 0);
assert_eq!(result.insertions, 1);
}
#[test]
fn test_diff_deletion() {
let old = "line one\nline two\nline three\n";
let new = "line one\nline three\n";
let result = diff_text(old, new);
assert_eq!(result.deletions, 1);
assert_eq!(result.insertions, 0);
}
#[test]
fn test_diff_multiple_hunks() {
let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
let new = "a\nB\nc\nd\ne\nf\ng\nH\ni\nj\n";
let result = diff_text(old, new);
assert_eq!(result.deletions, 2);
assert_eq!(result.insertions, 2);
assert_eq!(result.hunks.len(), 2);
}
#[test]
fn test_diff_context_lines() {
let old = "a\nb\nc\nd\ne\n";
let new = "a\nb\nC\nd\ne\n";
let result = diff_text(old, new);
assert_eq!(result.hunks.len(), 1);
let lines = &result.hunks[0].lines;
assert!(
lines
.iter()
.any(|l| matches!(l, DiffLine::Equal(s) if s.starts_with('b')))
);
assert!(lines.iter().any(|l| matches!(l, DiffLine::Modified { .. })));
assert!(
lines
.iter()
.any(|l| matches!(l, DiffLine::Equal(s) if s.starts_with('d')))
);
}
#[test]
fn test_diff_words_single_word_change() {
let (old_segs, new_segs) = diff_words("hello world", "hello earth");
assert!(
old_segs
.iter()
.any(|s| s.kind == WordSegmentKind::Changed && s.text == "world")
);
assert!(
new_segs
.iter()
.any(|s| s.kind == WordSegmentKind::Changed && s.text == "earth")
);
assert!(
old_segs
.iter()
.any(|s| s.kind == WordSegmentKind::Equal && s.text.contains("hello"))
);
}
#[test]
fn test_diff_words_no_change() {
let (old_segs, new_segs) = diff_words("same text", "same text");
assert!(old_segs.iter().all(|s| s.kind == WordSegmentKind::Equal));
assert!(new_segs.iter().all(|s| s.kind == WordSegmentKind::Equal));
}
#[test]
fn test_diff_words_completely_different() {
let (old_segs, new_segs) = diff_words("foo bar", "baz qux");
assert!(old_segs.iter().any(|s| s.kind == WordSegmentKind::Changed));
assert!(new_segs.iter().any(|s| s.kind == WordSegmentKind::Changed));
}
#[test]
fn test_pair_modifications_basic() {
let lines = vec![
DiffLine::Equal("context\n".to_string()),
DiffLine::Delete("old line\n".to_string()),
DiffLine::Insert("new line\n".to_string()),
DiffLine::Equal("context\n".to_string()),
];
let result = pair_modifications(lines);
assert_eq!(result.len(), 3); assert!(matches!(&result[1], DiffLine::Modified { .. }));
}
#[test]
fn test_pair_modifications_uneven_more_deletes() {
let lines = vec![
DiffLine::Delete("old 1\n".to_string()),
DiffLine::Delete("old 2\n".to_string()),
DiffLine::Delete("old 3\n".to_string()),
DiffLine::Insert("new 1\n".to_string()),
DiffLine::Insert("new 2\n".to_string()),
];
let result = pair_modifications(lines);
assert_eq!(result.len(), 3);
assert!(matches!(&result[0], DiffLine::Modified { .. }));
assert!(matches!(&result[1], DiffLine::Modified { .. }));
assert!(matches!(&result[2], DiffLine::Delete(_)));
}
#[test]
fn test_pair_modifications_uneven_more_inserts() {
let lines = vec![
DiffLine::Delete("old 1\n".to_string()),
DiffLine::Insert("new 1\n".to_string()),
DiffLine::Insert("new 2\n".to_string()),
];
let result = pair_modifications(lines);
assert_eq!(result.len(), 2);
assert!(matches!(&result[0], DiffLine::Modified { .. }));
assert!(matches!(&result[1], DiffLine::Insert(_)));
}
#[test]
fn test_pair_modifications_no_pairs() {
let lines = vec![
DiffLine::Equal("context\n".to_string()),
DiffLine::Insert("new\n".to_string()),
DiffLine::Equal("context\n".to_string()),
];
let result = pair_modifications(lines);
assert_eq!(result.len(), 3);
assert!(matches!(&result[1], DiffLine::Insert(_)));
}
#[test]
fn test_truncate_context_short_string() {
let s = "short string";
assert_eq!(truncate_context(s, 80), s);
}
#[test]
fn test_truncate_context_exact_length() {
let s = "a".repeat(80);
assert_eq!(truncate_context(&s, 80), s);
}
#[test]
fn test_truncate_context_long_string() {
let s = "a".repeat(200);
let truncated = truncate_context(&s, 80);
assert!(truncated.len() <= 85); assert!(truncated.contains(" ... "));
}
#[test]
fn test_truncate_context_preserves_ends() {
let s = "START middle padding that is quite long and should be truncated away END";
let truncated = truncate_context(&s, 40);
assert!(truncated.starts_with("START"));
assert!(truncated.ends_with("END"));
assert!(truncated.contains(" ... "));
}
#[test]
fn test_truncate_context_unicode() {
let s = "aaa\u{00e9}\u{00e9}\u{00e9}".repeat(20);
let truncated = truncate_context(&s, 40);
assert!(truncated.len() < s.len());
}
#[test]
fn test_diff_produces_modified_for_paired_changes() {
let old = "line one\nold paragraph content here\nline three\n";
let new = "line one\nnew paragraph content here\nline three\n";
let result = diff_text(old, new);
let has_modified = result.hunks.iter().any(|h| {
h.lines
.iter()
.any(|l| matches!(l, DiffLine::Modified { .. }))
});
assert!(has_modified, "Paired delete+insert should become Modified");
}
#[test]
fn test_modified_has_word_segments() {
let old = "The IRMA framework requires validation.\n";
let new = "The revised IRMA framework v2 requires validation.\n";
let result = diff_text(old, new);
for hunk in &result.hunks {
for line in &hunk.lines {
if let DiffLine::Modified {
old_segments,
new_segments,
} = line
{
assert!(
old_segments
.iter()
.any(|s| s.kind == WordSegmentKind::Equal)
);
let new_changed: String = new_segments
.iter()
.filter(|s| s.kind == WordSegmentKind::Changed)
.map(|s| s.text.as_str())
.collect();
assert!(
new_changed.contains("revised") || new_changed.contains("v2"),
"Expected changed words in new_segments, got: {}",
new_changed
);
return; }
}
}
panic!("Expected a Modified DiffLine");
}
#[test]
fn test_normalize_short_lines_unchanged() {
let text = "Line one.\nLine two.\nLine three.\n";
assert_eq!(normalize_for_diff(text), text);
}
#[test]
fn test_normalize_splits_long_paragraph_at_sentences() {
let text = "You are a helpful assistant. You should always respond politely. Use formal language when speaking to customers. Never share personal information. Always verify the caller's identity before proceeding.";
let normalized = normalize_for_diff(text);
let lines: Vec<&str> = normalized.lines().collect();
assert!(
lines.len() >= 4,
"Expected at least 4 sentence lines, got {}: {:?}",
lines.len(),
lines
);
assert!(lines[0].ends_with('.'));
assert!(lines[1].ends_with('.'));
}
#[test]
fn test_normalize_preserves_existing_multiline() {
let text = "Short line one.\nShort line two.\nShort line three.\n";
assert_eq!(normalize_for_diff(text), text);
}
#[test]
fn test_normalize_word_wraps_when_no_sentences() {
let text = "this is a very long line without any sentence boundaries or uppercase letters after periods and it just keeps going and going and going and going for a really long time";
let normalized = normalize_for_diff(text);
let lines: Vec<&str> = normalized.lines().collect();
assert!(
lines.len() > 1,
"Expected word-wrap to split into multiple lines, got: {:?}",
lines
);
for line in &lines {
assert!(
line.len() <= WORD_WRAP_WIDTH + 20,
"Line too long after wrapping: {} chars",
line.len()
);
}
}
#[test]
fn test_normalize_improves_diff_granularity() {
let old = "You are a helpful assistant. Always respond politely. Use formal language. Never share secrets.";
let new = "You are a helpful assistant. Always respond very politely. Use formal language. Never share secrets.";
let raw_result = diff_text(old, new);
let norm_old = normalize_for_diff(old);
let norm_new = normalize_for_diff(new);
let norm_result = diff_text(&norm_old, &norm_new);
assert!(
norm_result.deletions <= raw_result.deletions,
"Normalized diff should have same or fewer deletions"
);
assert_eq!(norm_result.deletions, 1);
assert_eq!(norm_result.insertions, 1);
}
}