1use similar::{ChangeTag, TextDiff};
8
9pub const LONG_TEXT_THRESHOLD: usize = 120;
11
12const SENTENCE_SPLIT_THRESHOLD: usize = 120;
14
15const WORD_WRAP_WIDTH: usize = 80;
17
18pub const CONTEXT_TRUNCATE_LEN: usize = 80;
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct WordSegment {
24 pub text: String,
25 pub kind: WordSegmentKind,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum WordSegmentKind {
31 Equal,
32 Changed,
33}
34
35#[derive(Debug, Clone, PartialEq)]
37pub enum DiffLine {
38 Equal(String),
40 Delete(String),
42 Insert(String),
44 Modified {
47 old_segments: Vec<WordSegment>,
48 new_segments: Vec<WordSegment>,
49 },
50}
51
52#[derive(Debug, Clone)]
54pub struct TextDiffHunk {
55 pub lines: Vec<DiffLine>,
56}
57
58#[derive(Debug, Clone)]
60pub struct TextDiffResult {
61 pub hunks: Vec<TextDiffHunk>,
62 pub deletions: usize,
63 pub insertions: usize,
64}
65
66pub fn is_long_text(s: &str) -> bool {
69 s.len() >= LONG_TEXT_THRESHOLD || s.contains('\n')
70}
71
72pub 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
114fn 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 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 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 for j in pair_count..delete_count {
161 result.push(lines[delete_start + j].clone());
162 }
163
164 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
177pub 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
217pub 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 return s.chars().take(max_len).collect();
232 }
233
234 let half = (max_len - ellipsis_len) / 2;
235
236 let prefix_end = s
238 .char_indices()
239 .nth(half)
240 .map(|(i, _)| i)
241 .unwrap_or(s.len());
242
243 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
255pub 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 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 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
308fn 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 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
335fn 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 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 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 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 #[test]
457 fn test_diff_words_single_word_change() {
458 let (old_segs, new_segs) = diff_words("hello world", "hello earth");
459 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 #[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); 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 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 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 #[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); 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 assert!(truncated.len() < s.len());
586 }
587
588 #[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 assert!(
619 old_segments
620 .iter()
621 .any(|s| s.kind == WordSegmentKind::Equal)
622 );
623 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; }
636 }
637 }
638 panic!("Expected a Modified DiffLine");
639 }
640
641 #[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 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 let raw_result = diff_text(old, new);
696 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 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}