Skip to main content

rigg_diff/
text_diff.rs

1//! Line-level text diffing for long text properties.
2//!
3//! Produces structured diffs with context lines for human-readable
4//! comparison of instructions, descriptions, and other long text fields.
5//! Supports word-level highlighting within modified paragraphs.
6
7use similar::{ChangeTag, TextDiff};
8
9/// Minimum string length to trigger line-level diffing.
10pub const LONG_TEXT_THRESHOLD: usize = 120;
11
12/// Per-line threshold: lines longer than this are split at sentence boundaries.
13const SENTENCE_SPLIT_THRESHOLD: usize = 120;
14
15/// Target width when word-wrapping lines with no sentence boundaries.
16const WORD_WRAP_WIDTH: usize = 80;
17
18/// Maximum display length for context lines before truncation.
19pub const CONTEXT_TRUNCATE_LEN: usize = 80;
20
21/// A segment of a word-level diff within a single line.
22#[derive(Debug, Clone, PartialEq)]
23pub struct WordSegment {
24    pub text: String,
25    pub kind: WordSegmentKind,
26}
27
28/// Whether a word segment is unchanged or changed.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum WordSegmentKind {
31    Equal,
32    Changed,
33}
34
35/// A single line in a text diff.
36#[derive(Debug, Clone, PartialEq)]
37pub enum DiffLine {
38    /// Unchanged context line.
39    Equal(String),
40    /// Deleted line (only in old text).
41    Delete(String),
42    /// Inserted line (only in new text).
43    Insert(String),
44    /// A modified line with word-level diff segments.
45    /// Contains old line segments and new line segments.
46    Modified {
47        old_segments: Vec<WordSegment>,
48        new_segments: Vec<WordSegment>,
49    },
50}
51
52/// A group of contiguous diff lines with surrounding context.
53#[derive(Debug, Clone)]
54pub struct TextDiffHunk {
55    pub lines: Vec<DiffLine>,
56}
57
58/// Result of a line-level text diff.
59#[derive(Debug, Clone)]
60pub struct TextDiffResult {
61    pub hunks: Vec<TextDiffHunk>,
62    pub deletions: usize,
63    pub insertions: usize,
64}
65
66/// Returns `true` if the string is long enough or multi-line to warrant
67/// line-level diffing instead of inline comparison.
68pub fn is_long_text(s: &str) -> bool {
69    s.len() >= LONG_TEXT_THRESHOLD || s.contains('\n')
70}
71
72/// Compute a line-level diff between two strings.
73///
74/// Returns hunks with 1 line of surrounding context per change group.
75/// Consecutive Delete+Insert pairs are converted to Modified entries
76/// with word-level diff segments.
77pub fn diff_text(old: &str, new: &str) -> TextDiffResult {
78    let diff = TextDiff::from_lines(old, new);
79    let mut deletions = 0;
80    let mut insertions = 0;
81    let mut hunks = Vec::new();
82
83    for group in diff.grouped_ops(1) {
84        let mut lines = Vec::new();
85        for op in &group {
86            for change in diff.iter_changes(op) {
87                let text = change.value().to_string();
88                match change.tag() {
89                    ChangeTag::Equal => lines.push(DiffLine::Equal(text)),
90                    ChangeTag::Delete => {
91                        deletions += 1;
92                        lines.push(DiffLine::Delete(text));
93                    }
94                    ChangeTag::Insert => {
95                        insertions += 1;
96                        lines.push(DiffLine::Insert(text));
97                    }
98                }
99            }
100        }
101        if !lines.is_empty() {
102            lines = pair_modifications(lines);
103            hunks.push(TextDiffHunk { lines });
104        }
105    }
106
107    TextDiffResult {
108        hunks,
109        deletions,
110        insertions,
111    }
112}
113
114/// Convert consecutive Delete+Insert runs into Modified entries with word-level diffs.
115///
116/// Pairs them 1:1 — if there are 3 deletes and 2 inserts, the first 2 become
117/// Modified pairs and the 3rd remains a standalone Delete.
118fn pair_modifications(lines: Vec<DiffLine>) -> Vec<DiffLine> {
119    let mut result = Vec::with_capacity(lines.len());
120    let mut i = 0;
121
122    while i < lines.len() {
123        // Look for a run of Delete lines followed by Insert lines
124        if matches!(&lines[i], DiffLine::Delete(_)) {
125            let delete_start = i;
126            while i < lines.len() && matches!(&lines[i], DiffLine::Delete(_)) {
127                i += 1;
128            }
129            let delete_end = i;
130
131            let insert_start = i;
132            while i < lines.len() && matches!(&lines[i], DiffLine::Insert(_)) {
133                i += 1;
134            }
135            let insert_end = i;
136
137            let delete_count = delete_end - delete_start;
138            let insert_count = insert_end - insert_start;
139            let pair_count = delete_count.min(insert_count);
140
141            // Pair deletes with inserts as Modified entries
142            for j in 0..pair_count {
143                let old_text = match &lines[delete_start + j] {
144                    DiffLine::Delete(s) => s.clone(),
145                    _ => unreachable!(),
146                };
147                let new_text = match &lines[insert_start + j] {
148                    DiffLine::Insert(s) => s.clone(),
149                    _ => unreachable!(),
150                };
151
152                let (old_segments, new_segments) = diff_words(&old_text, &new_text);
153                result.push(DiffLine::Modified {
154                    old_segments,
155                    new_segments,
156                });
157            }
158
159            // Emit remaining unpaired deletes
160            for j in pair_count..delete_count {
161                result.push(lines[delete_start + j].clone());
162            }
163
164            // Emit remaining unpaired inserts
165            for j in pair_count..insert_count {
166                result.push(lines[insert_start + j].clone());
167            }
168        } else {
169            result.push(lines[i].clone());
170            i += 1;
171        }
172    }
173
174    result
175}
176
177/// Compute word-level diff segments between two strings.
178///
179/// Returns (old_segments, new_segments) where each segment indicates
180/// whether it's equal or changed relative to the other side.
181pub fn diff_words(old: &str, new: &str) -> (Vec<WordSegment>, Vec<WordSegment>) {
182    let diff = TextDiff::from_words(old, new);
183    let mut old_segments = Vec::new();
184    let mut new_segments = Vec::new();
185
186    for change in diff.iter_all_changes() {
187        let text = change.value().to_string();
188        match change.tag() {
189            ChangeTag::Equal => {
190                old_segments.push(WordSegment {
191                    text: text.clone(),
192                    kind: WordSegmentKind::Equal,
193                });
194                new_segments.push(WordSegment {
195                    text,
196                    kind: WordSegmentKind::Equal,
197                });
198            }
199            ChangeTag::Delete => {
200                old_segments.push(WordSegment {
201                    text,
202                    kind: WordSegmentKind::Changed,
203                });
204            }
205            ChangeTag::Insert => {
206                new_segments.push(WordSegment {
207                    text,
208                    kind: WordSegmentKind::Changed,
209                });
210            }
211        }
212    }
213
214    (old_segments, new_segments)
215}
216
217/// Truncate a string to approximately `max_len` characters with `" ... "` in the middle.
218///
219/// If the string is shorter than or equal to `max_len`, returns it unchanged.
220/// Uses `char_indices()` for Unicode safety.
221pub fn truncate_context(s: &str, max_len: usize) -> String {
222    if s.len() <= max_len {
223        return s.to_string();
224    }
225
226    let ellipsis = " ... ";
227    let ellipsis_len = ellipsis.len();
228
229    if max_len <= ellipsis_len + 4 {
230        // Too short for meaningful truncation
231        return s.chars().take(max_len).collect();
232    }
233
234    let half = (max_len - ellipsis_len) / 2;
235
236    // Find the byte index for the prefix (first `half` chars)
237    let prefix_end = s
238        .char_indices()
239        .nth(half)
240        .map(|(i, _)| i)
241        .unwrap_or(s.len());
242
243    // Find the byte index for the suffix (last `half` chars)
244    let total_chars = s.chars().count();
245    let suffix_start_char = total_chars.saturating_sub(half);
246    let suffix_start = s
247        .char_indices()
248        .nth(suffix_start_char)
249        .map(|(i, _)| i)
250        .unwrap_or(s.len());
251
252    format!("{}{}{}", &s[..prefix_end], ellipsis, &s[suffix_start..])
253}
254
255/// Normalize text for more readable line-level diffs.
256///
257/// Long lines (single paragraphs without newlines) are split at sentence
258/// boundaries so that the diff can show which sentence changed, rather than
259/// showing the entire paragraph as one changed block.
260///
261/// Lines shorter than the threshold are left unchanged. When no sentence
262/// boundaries are found in a long line, it falls back to word-wrapping.
263pub fn normalize_for_diff(text: &str) -> String {
264    let mut result = String::new();
265    let parts: Vec<&str> = text.split('\n').collect();
266    let line_count = parts.len();
267    for (idx, line) in parts.into_iter().enumerate() {
268        // Preserve trailing newline semantics: if the original text ended with
269        // \n, the split produces a final empty element that we should skip.
270        if line.is_empty() && idx == line_count - 1 {
271            break;
272        }
273        if line.len() <= SENTENCE_SPLIT_THRESHOLD {
274            result.push_str(line);
275            result.push('\n');
276        } else {
277            let sentences = split_at_sentence_boundaries(line);
278            if sentences.len() <= 1 {
279                // No sentence boundaries found — word-wrap instead
280                let wrapped = word_wrap(line.trim(), WORD_WRAP_WIDTH);
281                for wrap_line in &wrapped {
282                    result.push_str(wrap_line);
283                    result.push('\n');
284                }
285            } else {
286                for sentence in &sentences {
287                    let trimmed = sentence.trim();
288                    if trimmed.is_empty() {
289                        continue;
290                    }
291                    if trimmed.len() > SENTENCE_SPLIT_THRESHOLD {
292                        let wrapped = word_wrap(trimmed, WORD_WRAP_WIDTH);
293                        for wrap_line in &wrapped {
294                            result.push_str(wrap_line);
295                            result.push('\n');
296                        }
297                    } else {
298                        result.push_str(trimmed);
299                        result.push('\n');
300                    }
301                }
302            }
303        }
304    }
305    result
306}
307
308/// Split text at sentence boundaries (`. `, `? `, `! ` followed by an
309/// uppercase letter). The punctuation stays with the preceding sentence.
310fn split_at_sentence_boundaries(text: &str) -> Vec<&str> {
311    let bytes = text.as_bytes();
312    let mut result = Vec::new();
313    let mut start = 0;
314
315    let mut i = 0;
316    while i + 2 < bytes.len() {
317        if (bytes[i] == b'.' || bytes[i] == b'?' || bytes[i] == b'!')
318            && bytes[i + 1] == b' '
319            && bytes[i + 2].is_ascii_uppercase()
320        {
321            // Include the punctuation on this segment, skip the space
322            result.push(&text[start..=i]);
323            start = i + 2;
324            i = start;
325        } else {
326            i += 1;
327        }
328    }
329    if start < text.len() {
330        result.push(&text[start..]);
331    }
332    result
333}
334
335/// Word-wrap text at the given width, breaking at space boundaries.
336fn word_wrap(text: &str, width: usize) -> Vec<String> {
337    let mut lines = Vec::new();
338    let mut current = String::new();
339
340    for word in text.split(' ') {
341        if word.is_empty() {
342            continue;
343        }
344        if current.is_empty() {
345            current.push_str(word);
346        } else if current.len() + 1 + word.len() > width {
347            lines.push(current);
348            current = word.to_string();
349        } else {
350            current.push(' ');
351            current.push_str(word);
352        }
353    }
354    if !current.is_empty() {
355        lines.push(current);
356    }
357    lines
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn test_is_long_text_short() {
366        assert!(!is_long_text("hello"));
367    }
368
369    #[test]
370    fn test_is_long_text_by_length() {
371        let s = "a".repeat(LONG_TEXT_THRESHOLD);
372        assert!(is_long_text(&s));
373    }
374
375    #[test]
376    fn test_is_long_text_multiline() {
377        assert!(is_long_text("line one\nline two"));
378    }
379
380    #[test]
381    fn test_diff_identical() {
382        let result = diff_text("hello\n", "hello\n");
383        assert_eq!(result.deletions, 0);
384        assert_eq!(result.insertions, 0);
385        assert!(result.hunks.is_empty());
386    }
387
388    #[test]
389    fn test_diff_single_line_change() {
390        let old = "line one\nline two\nline three\n";
391        let new = "line one\nline TWO\nline three\n";
392        let result = diff_text(old, new);
393        assert_eq!(result.deletions, 1);
394        assert_eq!(result.insertions, 1);
395        assert_eq!(result.hunks.len(), 1);
396
397        // Should produce a Modified entry (paired delete+insert)
398        let lines = &result.hunks[0].lines;
399        assert!(lines.iter().any(|l| matches!(l, DiffLine::Modified { .. })));
400    }
401
402    #[test]
403    fn test_diff_addition() {
404        let old = "line one\nline three\n";
405        let new = "line one\nline two\nline three\n";
406        let result = diff_text(old, new);
407        assert_eq!(result.deletions, 0);
408        assert_eq!(result.insertions, 1);
409    }
410
411    #[test]
412    fn test_diff_deletion() {
413        let old = "line one\nline two\nline three\n";
414        let new = "line one\nline three\n";
415        let result = diff_text(old, new);
416        assert_eq!(result.deletions, 1);
417        assert_eq!(result.insertions, 0);
418    }
419
420    #[test]
421    fn test_diff_multiple_hunks() {
422        let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
423        let new = "a\nB\nc\nd\ne\nf\ng\nH\ni\nj\n";
424        let result = diff_text(old, new);
425        assert_eq!(result.deletions, 2);
426        assert_eq!(result.insertions, 2);
427        // With 1 context line, changes at positions 2 and 8 (0-indexed)
428        // should form separate hunks since they're >2 lines apart
429        assert_eq!(result.hunks.len(), 2);
430    }
431
432    #[test]
433    fn test_diff_context_lines() {
434        let old = "a\nb\nc\nd\ne\n";
435        let new = "a\nb\nC\nd\ne\n";
436        let result = diff_text(old, new);
437        assert_eq!(result.hunks.len(), 1);
438
439        let lines = &result.hunks[0].lines;
440        // With 1 context line, we should have: b (context), Modified(c->C), d (context)
441        assert!(
442            lines
443                .iter()
444                .any(|l| matches!(l, DiffLine::Equal(s) if s.starts_with('b')))
445        );
446        assert!(lines.iter().any(|l| matches!(l, DiffLine::Modified { .. })));
447        assert!(
448            lines
449                .iter()
450                .any(|l| matches!(l, DiffLine::Equal(s) if s.starts_with('d')))
451        );
452    }
453
454    // === Word-level diff tests ===
455
456    #[test]
457    fn test_diff_words_single_word_change() {
458        let (old_segs, new_segs) = diff_words("hello world", "hello earth");
459        // "hello " is equal, "world"/"earth" is changed
460        assert!(
461            old_segs
462                .iter()
463                .any(|s| s.kind == WordSegmentKind::Changed && s.text == "world")
464        );
465        assert!(
466            new_segs
467                .iter()
468                .any(|s| s.kind == WordSegmentKind::Changed && s.text == "earth")
469        );
470        assert!(
471            old_segs
472                .iter()
473                .any(|s| s.kind == WordSegmentKind::Equal && s.text.contains("hello"))
474        );
475    }
476
477    #[test]
478    fn test_diff_words_no_change() {
479        let (old_segs, new_segs) = diff_words("same text", "same text");
480        assert!(old_segs.iter().all(|s| s.kind == WordSegmentKind::Equal));
481        assert!(new_segs.iter().all(|s| s.kind == WordSegmentKind::Equal));
482    }
483
484    #[test]
485    fn test_diff_words_completely_different() {
486        let (old_segs, new_segs) = diff_words("foo bar", "baz qux");
487        assert!(old_segs.iter().any(|s| s.kind == WordSegmentKind::Changed));
488        assert!(new_segs.iter().any(|s| s.kind == WordSegmentKind::Changed));
489    }
490
491    // === pair_modifications tests ===
492
493    #[test]
494    fn test_pair_modifications_basic() {
495        let lines = vec![
496            DiffLine::Equal("context\n".to_string()),
497            DiffLine::Delete("old line\n".to_string()),
498            DiffLine::Insert("new line\n".to_string()),
499            DiffLine::Equal("context\n".to_string()),
500        ];
501        let result = pair_modifications(lines);
502        assert_eq!(result.len(), 3); // Equal, Modified, Equal
503        assert!(matches!(&result[1], DiffLine::Modified { .. }));
504    }
505
506    #[test]
507    fn test_pair_modifications_uneven_more_deletes() {
508        let lines = vec![
509            DiffLine::Delete("old 1\n".to_string()),
510            DiffLine::Delete("old 2\n".to_string()),
511            DiffLine::Delete("old 3\n".to_string()),
512            DiffLine::Insert("new 1\n".to_string()),
513            DiffLine::Insert("new 2\n".to_string()),
514        ];
515        let result = pair_modifications(lines);
516        // 2 Modified + 1 leftover Delete
517        assert_eq!(result.len(), 3);
518        assert!(matches!(&result[0], DiffLine::Modified { .. }));
519        assert!(matches!(&result[1], DiffLine::Modified { .. }));
520        assert!(matches!(&result[2], DiffLine::Delete(_)));
521    }
522
523    #[test]
524    fn test_pair_modifications_uneven_more_inserts() {
525        let lines = vec![
526            DiffLine::Delete("old 1\n".to_string()),
527            DiffLine::Insert("new 1\n".to_string()),
528            DiffLine::Insert("new 2\n".to_string()),
529        ];
530        let result = pair_modifications(lines);
531        // 1 Modified + 1 leftover Insert
532        assert_eq!(result.len(), 2);
533        assert!(matches!(&result[0], DiffLine::Modified { .. }));
534        assert!(matches!(&result[1], DiffLine::Insert(_)));
535    }
536
537    #[test]
538    fn test_pair_modifications_no_pairs() {
539        let lines = vec![
540            DiffLine::Equal("context\n".to_string()),
541            DiffLine::Insert("new\n".to_string()),
542            DiffLine::Equal("context\n".to_string()),
543        ];
544        let result = pair_modifications(lines);
545        assert_eq!(result.len(), 3);
546        assert!(matches!(&result[1], DiffLine::Insert(_)));
547    }
548
549    // === truncate_context tests ===
550
551    #[test]
552    fn test_truncate_context_short_string() {
553        let s = "short string";
554        assert_eq!(truncate_context(s, 80), s);
555    }
556
557    #[test]
558    fn test_truncate_context_exact_length() {
559        let s = "a".repeat(80);
560        assert_eq!(truncate_context(&s, 80), s);
561    }
562
563    #[test]
564    fn test_truncate_context_long_string() {
565        let s = "a".repeat(200);
566        let truncated = truncate_context(&s, 80);
567        assert!(truncated.len() <= 85); // allow slight overshoot from char boundaries
568        assert!(truncated.contains(" ... "));
569    }
570
571    #[test]
572    fn test_truncate_context_preserves_ends() {
573        let s = "START middle padding that is quite long and should be truncated away END";
574        let truncated = truncate_context(&s, 40);
575        assert!(truncated.starts_with("START"));
576        assert!(truncated.ends_with("END"));
577        assert!(truncated.contains(" ... "));
578    }
579
580    #[test]
581    fn test_truncate_context_unicode() {
582        let s = "aaa\u{00e9}\u{00e9}\u{00e9}".repeat(20);
583        let truncated = truncate_context(&s, 40);
584        // Should not panic on unicode boundaries
585        assert!(truncated.len() < s.len());
586    }
587
588    // === Modified variant in diff output ===
589
590    #[test]
591    fn test_diff_produces_modified_for_paired_changes() {
592        let old = "line one\nold paragraph content here\nline three\n";
593        let new = "line one\nnew paragraph content here\nline three\n";
594        let result = diff_text(old, new);
595
596        let has_modified = result.hunks.iter().any(|h| {
597            h.lines
598                .iter()
599                .any(|l| matches!(l, DiffLine::Modified { .. }))
600        });
601        assert!(has_modified, "Paired delete+insert should become Modified");
602    }
603
604    #[test]
605    fn test_modified_has_word_segments() {
606        let old = "The IRMA framework requires validation.\n";
607        let new = "The revised IRMA framework v2 requires validation.\n";
608        let result = diff_text(old, new);
609
610        for hunk in &result.hunks {
611            for line in &hunk.lines {
612                if let DiffLine::Modified {
613                    old_segments,
614                    new_segments,
615                } = line
616                {
617                    // Old side should have equal segments and no "revised"/"v2"
618                    assert!(
619                        old_segments
620                            .iter()
621                            .any(|s| s.kind == WordSegmentKind::Equal)
622                    );
623                    // New side should have "revised" and "v2" as changed
624                    let new_changed: String = new_segments
625                        .iter()
626                        .filter(|s| s.kind == WordSegmentKind::Changed)
627                        .map(|s| s.text.as_str())
628                        .collect();
629                    assert!(
630                        new_changed.contains("revised") || new_changed.contains("v2"),
631                        "Expected changed words in new_segments, got: {}",
632                        new_changed
633                    );
634                    return; // Found and verified
635                }
636            }
637        }
638        panic!("Expected a Modified DiffLine");
639    }
640
641    // === normalize_for_diff tests ===
642
643    #[test]
644    fn test_normalize_short_lines_unchanged() {
645        let text = "Line one.\nLine two.\nLine three.\n";
646        assert_eq!(normalize_for_diff(text), text);
647    }
648
649    #[test]
650    fn test_normalize_splits_long_paragraph_at_sentences() {
651        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.";
652        let normalized = normalize_for_diff(text);
653        let lines: Vec<&str> = normalized.lines().collect();
654        assert!(
655            lines.len() >= 4,
656            "Expected at least 4 sentence lines, got {}: {:?}",
657            lines.len(),
658            lines
659        );
660        assert!(lines[0].ends_with('.'));
661        assert!(lines[1].ends_with('.'));
662    }
663
664    #[test]
665    fn test_normalize_preserves_existing_multiline() {
666        let text = "Short line one.\nShort line two.\nShort line three.\n";
667        assert_eq!(normalize_for_diff(text), text);
668    }
669
670    #[test]
671    fn test_normalize_word_wraps_when_no_sentences() {
672        // A long line with no sentence boundaries (no ". A" pattern)
673        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";
674        let normalized = normalize_for_diff(text);
675        let lines: Vec<&str> = normalized.lines().collect();
676        assert!(
677            lines.len() > 1,
678            "Expected word-wrap to split into multiple lines, got: {:?}",
679            lines
680        );
681        for line in &lines {
682            assert!(
683                line.len() <= WORD_WRAP_WIDTH + 20,
684                "Line too long after wrapping: {} chars",
685                line.len()
686            );
687        }
688    }
689
690    #[test]
691    fn test_normalize_improves_diff_granularity() {
692        let old = "You are a helpful assistant. Always respond politely. Use formal language. Never share secrets.";
693        let new = "You are a helpful assistant. Always respond very politely. Use formal language. Never share secrets.";
694        // Without normalization: 1 delete + 1 insert (whole paragraph)
695        let raw_result = diff_text(old, new);
696        // With normalization: only the changed sentence differs
697        let norm_old = normalize_for_diff(old);
698        let norm_new = normalize_for_diff(new);
699        let norm_result = diff_text(&norm_old, &norm_new);
700        // The normalized version should have fewer changed lines
701        assert!(
702            norm_result.deletions <= raw_result.deletions,
703            "Normalized diff should have same or fewer deletions"
704        );
705        assert_eq!(norm_result.deletions, 1);
706        assert_eq!(norm_result.insertions, 1);
707    }
708}