metrics.rs

  1use collections::HashMap;
  2
  3use crate::{
  4    example::ActualCursor,
  5    reorder_patch::{Patch, PatchLine},
  6};
  7
  8pub type Counts = HashMap<String, usize>;
  9type CountsDelta = HashMap<String, isize>;
 10
 11/// Context characters needed on each side of a change to capture all affected n-grams
 12const CONTEXT_CHARS: usize = CHR_F_CHAR_ORDER - 1;
 13
 14#[derive(Default, Debug, Clone)]
 15pub struct ClassificationMetrics {
 16    pub true_positives: usize,
 17    pub false_positives: usize,
 18    pub false_negatives: usize,
 19}
 20
 21impl ClassificationMetrics {
 22    pub fn from_counts(expected: &Counts, actual: &Counts) -> ClassificationMetrics {
 23        let mut true_positives = 0;
 24        let mut false_positives = 0;
 25        let mut false_negatives = 0;
 26
 27        for (ngram, &expected_count) in expected {
 28            let actual_count = *actual.get(ngram).unwrap_or(&0);
 29            if actual_count > expected_count {
 30                false_positives += actual_count - expected_count;
 31            } else {
 32                false_negatives += expected_count - actual_count;
 33            }
 34            true_positives += expected_count.min(actual_count);
 35        }
 36
 37        for (ngram, &actual_count) in actual {
 38            if !expected.contains_key(ngram) {
 39                false_positives += actual_count;
 40            }
 41        }
 42
 43        ClassificationMetrics {
 44            true_positives,
 45            false_positives,
 46            false_negatives,
 47        }
 48    }
 49
 50    pub fn precision(&self) -> f64 {
 51        if self.true_positives + self.false_positives == 0 {
 52            0.0
 53        } else {
 54            self.true_positives as f64 / (self.true_positives + self.false_positives) as f64
 55        }
 56    }
 57
 58    pub fn recall(&self) -> f64 {
 59        if self.true_positives + self.false_negatives == 0 {
 60            0.0
 61        } else {
 62            self.true_positives as f64 / (self.true_positives + self.false_negatives) as f64
 63        }
 64    }
 65
 66    pub fn f1(&self) -> f64 {
 67        let precision = self.precision();
 68        let recall = self.recall();
 69        if precision + recall == 0.0 {
 70            0.0
 71        } else {
 72            2.0 * precision * recall / (precision + recall)
 73        }
 74    }
 75}
 76
 77enum ChrfWhitespace {
 78    #[allow(unused)]
 79    Unchanged,
 80    Ignore,
 81}
 82
 83const CHR_F_CHAR_ORDER: usize = 6;
 84const CHR_F_BETA: f64 = 2.0;
 85const CHR_F_WHITESPACE: ChrfWhitespace = ChrfWhitespace::Ignore;
 86
 87/// Computes a delta-chrF score that compares two sets of edits.
 88///
 89/// This metric works by:
 90/// 1. Computing n-gram count differences (deltas) between original→expected and original→actual
 91/// 2. Comparing these deltas to measure how well actual edits match expected edits
 92///
 93/// Returns a score from 0.0 to 100.0, where 100.0 means the actual edits perfectly match
 94/// the expected edits.
 95pub fn delta_chr_f(original: &str, expected: &str, actual: &str) -> f64 {
 96    // Edge case: if all texts are identical, the edits match perfectly
 97    if original == expected && expected == actual {
 98        return 100.0;
 99    }
100
101    // Pre-filter whitespace once for all texts
102    let orig_chars: Vec<char> = filter_whitespace_chars(original);
103    let exp_chars: Vec<char> = filter_whitespace_chars(expected);
104    let act_chars: Vec<char> = filter_whitespace_chars(actual);
105
106    // Find the changed regions between original→expected and original→actual
107    // We only need to compute n-grams on these regions (plus context for boundary n-grams)
108    let (orig_for_exp, exp_region) = extract_changed_regions(&orig_chars, &exp_chars);
109    let (orig_for_act, act_region) = extract_changed_regions(&orig_chars, &act_chars);
110
111    let mut total_precision = 0.0;
112    let mut total_recall = 0.0;
113
114    for order in 1..=CHR_F_CHAR_ORDER {
115        // Compute n-grams only on the affected regions
116        let orig_ngrams_for_exp = count_ngrams_from_chars(&orig_for_exp, order);
117        let exp_ngrams = count_ngrams_from_chars(&exp_region, order);
118        let expected_delta = compute_ngram_delta(&exp_ngrams, &orig_ngrams_for_exp);
119
120        let orig_ngrams_for_act = count_ngrams_from_chars(&orig_for_act, order);
121        let act_ngrams = count_ngrams_from_chars(&act_region, order);
122        let actual_delta = compute_ngram_delta(&act_ngrams, &orig_ngrams_for_act);
123
124        if expected_delta.is_empty() && actual_delta.is_empty() {
125            total_precision += 1.0;
126            total_recall += 1.0;
127            continue;
128        }
129
130        let expected_counts = ngram_delta_to_counts(&expected_delta);
131        let actual_counts = ngram_delta_to_counts(&actual_delta);
132
133        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
134        total_precision += score.precision();
135        total_recall += score.recall();
136    }
137
138    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
139    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
140    let f_score = if prec + recall == 0.0 {
141        0.0
142    } else {
143        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
144    };
145
146    f_score * 100.0
147}
148
149/// Reference implementation of delta_chr_f (original, non-optimized version).
150/// Used for testing that the optimized version produces identical results.
151#[cfg(test)]
152fn delta_chr_f_reference(original: &str, expected: &str, actual: &str) -> f64 {
153    if original == expected && expected == actual {
154        return 100.0;
155    }
156
157    let original_ngrams = chr_f_ngram_counts(original);
158    let expected_ngrams = chr_f_ngram_counts(expected);
159    let actual_ngrams = chr_f_ngram_counts(actual);
160
161    let mut total_precision = 0.0;
162    let mut total_recall = 0.0;
163
164    for order in 0..CHR_F_CHAR_ORDER {
165        let expected_delta = compute_ngram_delta(&expected_ngrams[order], &original_ngrams[order]);
166        let actual_delta = compute_ngram_delta(&actual_ngrams[order], &original_ngrams[order]);
167
168        if expected_delta.is_empty() && actual_delta.is_empty() {
169            total_precision += 1.0;
170            total_recall += 1.0;
171            continue;
172        }
173
174        let expected_counts = ngram_delta_to_counts(&expected_delta);
175        let actual_counts = ngram_delta_to_counts(&actual_delta);
176
177        let score = ClassificationMetrics::from_counts(&expected_counts, &actual_counts);
178        total_precision += score.precision();
179        total_recall += score.recall();
180    }
181
182    let prec = total_precision / CHR_F_CHAR_ORDER as f64;
183    let recall = total_recall / CHR_F_CHAR_ORDER as f64;
184    let f_score = if prec + recall == 0.0 {
185        0.0
186    } else {
187        (1.0 + CHR_F_BETA * CHR_F_BETA) * prec * recall / (CHR_F_BETA * CHR_F_BETA * prec + recall)
188    };
189
190    f_score * 100.0
191}
192
193/// Filter whitespace from a string and return as Vec<char>
194fn filter_whitespace_chars(text: &str) -> Vec<char> {
195    match CHR_F_WHITESPACE {
196        ChrfWhitespace::Unchanged => text.chars().collect(),
197        ChrfWhitespace::Ignore => text.chars().filter(|c| !c.is_whitespace()).collect(),
198    }
199}
200
201/// Extract only the changed regions between two texts, with context for n-gram boundaries.
202///
203/// Returns (original_affected_region, modified_affected_region) as Vec<char>.
204///
205/// The key insight: when computing n-gram delta between two nearly-identical texts,
206/// n-grams from unchanged regions cancel out. We only need to process:
207/// 1. The changed content itself
208/// 2. CONTEXT_CHARS (n-1) characters before and after, to capture boundary-crossing n-grams
209fn extract_changed_regions(original: &[char], modified: &[char]) -> (Vec<char>, Vec<char>) {
210    // Find longest common prefix
211    let prefix_len = original
212        .iter()
213        .zip(modified.iter())
214        .take_while(|(a, b)| a == b)
215        .count();
216
217    // Find longest common suffix (that doesn't overlap with prefix)
218    let orig_remaining = original.len().saturating_sub(prefix_len);
219    let mod_remaining = modified.len().saturating_sub(prefix_len);
220    let max_suffix = orig_remaining.min(mod_remaining);
221
222    let suffix_len = original
223        .iter()
224        .rev()
225        .zip(modified.iter().rev())
226        .take(max_suffix)
227        .take_while(|(a, b)| a == b)
228        .count();
229
230    // Calculate the changed region boundaries
231    let orig_change_start = prefix_len;
232    let orig_change_end = original.len().saturating_sub(suffix_len);
233    let mod_change_start = prefix_len;
234    let mod_change_end = modified.len().saturating_sub(suffix_len);
235
236    // If there's no actual change, return empty regions
237    if orig_change_start >= orig_change_end && mod_change_start >= mod_change_end {
238        return (Vec::new(), Vec::new());
239    }
240
241    // Expand to include context for n-gram boundaries
242    let orig_context_start = orig_change_start.saturating_sub(CONTEXT_CHARS);
243    let orig_context_end = (orig_change_end + CONTEXT_CHARS).min(original.len());
244    let mod_context_start = mod_change_start.saturating_sub(CONTEXT_CHARS);
245    let mod_context_end = (mod_change_end + CONTEXT_CHARS).min(modified.len());
246
247    let orig_region: Vec<char> = original[orig_context_start..orig_context_end].to_vec();
248    let mod_region: Vec<char> = modified[mod_context_start..mod_context_end].to_vec();
249
250    (orig_region, mod_region)
251}
252
253/// Count n-grams directly from a char slice (avoids String allocation for the full text)
254fn count_ngrams_from_chars(chars: &[char], n: usize) -> Counts {
255    let mut counts = Counts::default();
256
257    if chars.len() < n {
258        return counts;
259    }
260
261    for window in chars.windows(n) {
262        let ngram: String = window.iter().collect();
263        *counts.entry(ngram).or_insert(0) += 1;
264    }
265
266    counts
267}
268
269#[allow(dead_code)]
270fn chr_f_ngram_counts(text: &str) -> Vec<Counts> {
271    // Ignore whitespace. The original chrF implementation skips all
272    // whitespace. We should consider compressing multiple consecutive
273    // spaces into one -- this may reflect our task more closely.
274    let text = match CHR_F_WHITESPACE {
275        ChrfWhitespace::Unchanged => text.to_string(),
276        ChrfWhitespace::Ignore => text
277            .chars()
278            .filter(|c| !c.is_whitespace())
279            .collect::<String>(),
280    };
281
282    (1..=CHR_F_CHAR_ORDER)
283        .map(|order| count_ngrams(&text, order))
284        .collect()
285}
286
287fn compute_ngram_delta(after: &Counts, before: &Counts) -> CountsDelta {
288    let mut delta = CountsDelta::default();
289
290    for (ngram, &before_count) in before {
291        let after_count = *after.get(ngram).unwrap_or(&0);
292        delta.insert(ngram.clone(), after_count as isize - before_count as isize);
293    }
294
295    for (ngram, &after_count) in after {
296        if !before.contains_key(ngram) {
297            delta.insert(ngram.clone(), after_count as isize);
298        }
299    }
300
301    delta
302}
303
304/// Convert negative counts to special deletion tokens.
305/// For example, if expected delta is {"foo": -1} and actual delta is {"bar": -1},
306/// we convert it to {"¬foo": +1} and {"¬bar": +1}. This way _not_ deleting "foo"
307/// will result in a false negative, and mistakenly deleting "bar" will result in a false positive.
308fn ngram_delta_to_counts(delta: &CountsDelta) -> Counts {
309    let mut counts = Counts::default();
310
311    for (ngram, &delta) in delta {
312        if delta > 0 {
313            counts.insert(ngram.clone(), delta as usize);
314        } else if delta < 0 {
315            counts.insert(format!("¬{ngram}"), delta.unsigned_abs());
316        }
317    }
318
319    counts
320}
321
322#[allow(dead_code)]
323fn count_ngrams(text: &str, n: usize) -> Counts {
324    let chars: Vec<char> = text.chars().collect();
325    let mut counts = Counts::default();
326
327    for window in chars.windows(n) {
328        let ngram: String = window.iter().collect();
329        *counts.entry(ngram).or_insert(0) += 1;
330    }
331
332    counts
333}
334
335pub fn braces_disbalance(text: &str) -> usize {
336    let mut disbalance = 0isize;
337
338    let a = text.chars().filter(|&c| c == '{').count() as isize;
339    let b = text.chars().filter(|&c| c == '}').count() as isize;
340    disbalance += (a - b).abs();
341
342    let a = text.chars().filter(|&c| c == '(').count() as isize;
343    let b = text.chars().filter(|&c| c == ')').count() as isize;
344    disbalance += (a - b).abs();
345
346    let a = text.chars().filter(|&c| c == '[').count() as isize;
347    let b = text.chars().filter(|&c| c == ']').count() as isize;
348    disbalance += (a - b).abs();
349
350    disbalance as usize
351}
352
353/// Extracts changed lines from a unified diff string.
354/// Returns a bag (multiset) of lines that were added (+) or removed (-).
355/// The +/- prefix is included in the line to distinguish additions from deletions.
356pub fn extract_changed_lines_from_diff(diff: &str) -> Counts {
357    let mut counts = Counts::default();
358
359    for line in diff.lines() {
360        // Skip file headers (--- and +++)
361        if line.starts_with("---") || line.starts_with("+++") {
362            continue;
363        }
364        // Skip hunk headers (@@)
365        if line.starts_with("@@") {
366            continue;
367        }
368        // Skip diff header lines (diff --git, index, etc.)
369        if line.starts_with("diff ") || line.starts_with("index ") {
370            continue;
371        }
372        // Include added and removed lines (with their prefix)
373        if line.starts_with('+') || line.starts_with('-') {
374            *counts.entry(line.to_string()).or_insert(0) += 1;
375        }
376    }
377
378    counts
379}
380
381/// Computes exact lines match metrics between expected and actual patches.
382/// Treats changed lines as a bag (multiset) - order is discarded but count matters.
383/// Returns ClassificationMetrics with TP/FP/FN counts.
384pub fn exact_lines_match(expected_patch: &str, actual_patch: &str) -> ClassificationMetrics {
385    let expected_lines = extract_changed_lines_from_diff(expected_patch);
386    let actual_lines = extract_changed_lines_from_diff(actual_patch);
387    ClassificationMetrics::from_counts(&expected_lines, &actual_lines)
388}
389
390/// Returns whether the patch contains any isolated whitespace-only changes.
391///
392/// A whitespace-only change is an added or deleted line whose content is empty or
393/// contains only whitespace. It is "isolated" when it is not adjacent to any
394/// substantive (non-whitespace) change within the same contiguous change group.
395pub fn has_isolated_whitespace_changes(patch_str: &str, cursor: Option<&ActualCursor>) -> bool {
396    let patch = Patch::parse_unified_diff(patch_str);
397
398    let cursor_new_file_line = cursor.as_ref().map(|c| (c.row + 1) as usize);
399
400    for hunk in &patch.hunks {
401        let lines = &hunk.lines;
402        let mut new_text_line = hunk.new_start as usize;
403
404        for (i, line) in lines.iter().enumerate() {
405            let content = match line {
406                PatchLine::Addition(s) => {
407                    let addition_line = new_text_line;
408                    new_text_line += 1;
409                    if s.trim().is_empty() && cursor_new_file_line == Some(addition_line) {
410                        continue;
411                    }
412                    s.as_str()
413                }
414                PatchLine::Deletion(s) => s.as_str(),
415                PatchLine::Context(_) => {
416                    new_text_line += 1;
417                    continue;
418                }
419                _ => continue,
420            };
421
422            if !content.trim().is_empty() {
423                continue;
424            }
425
426            if is_whitespace_change_isolated(lines, i) {
427                return true;
428            }
429        }
430    }
431
432    false
433}
434
435fn is_whitespace_change_isolated(lines: &[PatchLine], index: usize) -> bool {
436    // Look backward for a non-whitespace change before hitting a context line
437    for line in lines[..index].iter().rev() {
438        match line {
439            PatchLine::Addition(s) | PatchLine::Deletion(s) => {
440                if !s.trim().is_empty() {
441                    return false;
442                }
443            }
444            _ => break,
445        }
446    }
447
448    // Look forward for a non-whitespace change before hitting a context line
449    for line in &lines[index + 1..] {
450        match line {
451            PatchLine::Addition(s) | PatchLine::Deletion(s) => {
452                if !s.trim().is_empty() {
453                    return false;
454                }
455            }
456            _ => break,
457        }
458    }
459
460    true
461}
462
463/// A simple proxy for whether the prediction respects editable region.
464pub fn is_editable_region_correct(actual_patch: &str) -> bool {
465    // A typical sign of a wrong editable region: a bunch of lines deletion
466    // at the beginning or end of the patch.
467    let patch = Patch::parse_unified_diff(actual_patch);
468    if patch.hunks.is_empty() {
469        return true;
470    }
471
472    let hunk = &patch.hunks[0];
473    let mut deletions_at_start = 0;
474
475    for line in hunk.lines.iter() {
476        match line {
477            PatchLine::Deletion(_) => deletions_at_start += 1,
478            _ => break,
479        }
480    }
481
482    if deletions_at_start >= 3 {
483        return false;
484    }
485
486    true
487}
488
489#[cfg(test)]
490mod test_optimization {
491    use super::*;
492
493    #[test]
494    fn test_extract_changed_regions_simple() {
495        let original: Vec<char> = "hello world".chars().collect();
496        let modified: Vec<char> = "hello there".chars().collect();
497
498        let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
499
500        // "world" vs "there" - with 5 chars context, we get "ello world" vs "ello there"
501        // (or less if not enough chars available)
502        assert!(orig_region.len() < original.len());
503        assert!(mod_region.len() < modified.len());
504    }
505
506    #[test]
507    fn test_extract_changed_regions_insertion() {
508        let original: Vec<char> = "abcdef".chars().collect();
509        let modified: Vec<char> = "abcXYZdef".chars().collect();
510
511        let (orig_region, mod_region) = extract_changed_regions(&original, &modified);
512
513        // The insertion is between c and d, so we need context around that point
514        assert!(orig_region.len() <= original.len());
515        assert!(mod_region.iter().collect::<String>().contains("XYZ"));
516    }
517
518    #[test]
519    fn test_extract_changed_regions_identical() {
520        let text: Vec<char> = "identical text".chars().collect();
521
522        let (orig_region, mod_region) = extract_changed_regions(&text, &text);
523
524        // When texts are identical, regions should be empty
525        assert!(orig_region.is_empty());
526        assert!(mod_region.is_empty());
527    }
528
529    #[test]
530    fn test_optimized_matches_original_score() {
531        // Test that our optimized version produces the same results
532        let test_cases = vec![
533            ("hello world", "hello there", "hello world"),
534            (
535                "fn main() {}",
536                "fn main() { println!(); }",
537                "fn main() { print!(); }",
538            ),
539            ("abcdefghij", "abcXXXghij", "abcYYghij"),
540            ("unchanged", "unchanged", "unchanged"),
541            (
542                "prefix middle suffix",
543                "prefix CHANGED suffix",
544                "prefix middle suffix",
545            ),
546        ];
547
548        for (original, expected, actual) in test_cases {
549            let score = delta_chr_f(original, expected, actual);
550            // Just verify it produces a reasonable score (0-100)
551            assert!(
552                score >= 0.0 && score <= 100.0,
553                "Score {} out of range for ({}, {}, {})",
554                score,
555                original,
556                expected,
557                actual
558            );
559        }
560    }
561
562    #[test]
563    fn test_optimized_equals_reference() {
564        // Comprehensive test that optimized version matches reference implementation exactly
565        let test_cases = vec![
566            // Basic cases
567            ("hello world", "hello there", "hello world"),
568            ("hello world", "hello there", "hello there"),
569            ("unchanged", "unchanged", "unchanged"),
570            // Code-like cases
571            (
572                "fn main() { println!(\"Hello\"); }",
573                "fn main() { println!(\"Hello, World!\"); }",
574                "fn main() { println!(\"Hello, World!\"); }",
575            ),
576            (
577                "fn main() { println!(\"Hello\"); }",
578                "fn main() { println!(\"Hello, World!\"); }",
579                "fn main() { println!(\"Goodbye\"); }",
580            ),
581            // Insertion
582            ("abcdef", "abcXYZdef", "abcdef"),
583            ("abcdef", "abcXYZdef", "abcXYZdef"),
584            ("abcdef", "abcXYZdef", "abcABCdef"),
585            // Deletion
586            ("abcXYZdef", "abcdef", "abcXYZdef"),
587            ("abcXYZdef", "abcdef", "abcdef"),
588            // Multiple changes (simulated by different expected/actual)
589            ("one two three four", "one THREE four", "one two FOUR"),
590            // Edge cases
591            ("a", "b", "c"),
592            ("", "abc", ""),
593            ("abc", "", "abc"),
594            // Longer text with small change
595            (
596                "This is a longer piece of text that contains many words and characters to process",
597                "This is a longer piece of TEXT that contains many words and characters to process",
598                "This is a longer piece of text that contains many words and characters to process",
599            ),
600            // Change at the beginning
601            (
602                "ORIGINAL start of text",
603                "NEW start of text",
604                "DIFFERENT start of text",
605            ),
606            // Change at the end
607            (
608                "text ending ORIGINAL",
609                "text ending NEW",
610                "text ending DIFFERENT",
611            ),
612            // Whitespace (should be ignored)
613            ("hello   world", "hello   there", "hello   world"),
614            ("a b c d", "a X c d", "a Y c d"),
615        ];
616
617        for (original, expected, actual) in test_cases {
618            let optimized_score = delta_chr_f(original, expected, actual);
619            let reference_score = delta_chr_f_reference(original, expected, actual);
620
621            assert!(
622                (optimized_score - reference_score).abs() < 1e-10,
623                "Mismatch for ({:?}, {:?}, {:?}):\n  optimized: {}\n  reference: {}",
624                original,
625                expected,
626                actual,
627                optimized_score,
628                reference_score
629            );
630        }
631    }
632}
633
634#[cfg(test)]
635mod test {
636    use super::*;
637    use crate::example::ActualCursor;
638    use indoc::indoc;
639
640    fn cursor_on_line(one_based_line: u32) -> ActualCursor {
641        ActualCursor {
642            path: String::new(),
643            row: one_based_line - 1,
644            column: 0,
645            offset: 0,
646            editable_region_offset: None,
647        }
648    }
649
650    #[test]
651    fn test_delta_chr_f_perfect_match() {
652        let original = "fn main() {    println!(\"Hello\");}";
653        let expected = "fn main() {    println!(\"Hello, World!\");}";
654
655        let score = delta_chr_f(original, expected, expected);
656        assert!((score - 100.0).abs() < 1e-2);
657    }
658
659    #[test]
660    fn test_delta_chr_f_wrong_edit() {
661        // When the edit is wrong
662        let original = "one two three";
663        let expected = "one three"; // deleted "two "
664        let actual = "one two four"; // deleted "three", added "four"
665
666        // Then the score should be low
667        let score = delta_chr_f(original, expected, actual);
668        assert!(score > 20.0 && score < 40.0);
669    }
670
671    #[test]
672    fn test_delta_chr_f_partial_match() {
673        let original = "let x = 42;";
674        let expected = "let x = 100;";
675        let actual = "let x = 99;";
676
677        // We got the edit location right, but the replacement text is wrong.
678        // Deleted ngrams will match, bringing the score somewhere in the middle.
679        let score = delta_chr_f(original, expected, actual);
680        assert!(score > 40.0 && score < 60.0);
681    }
682
683    #[test]
684    fn test_delta_chr_f_missed_edit() {
685        // When predictions makes no changes
686        let original = "prefix old suffix";
687        let expected = "prefix new suffix";
688        let actual = "prefix old suffix"; // no change
689
690        // Then the score should be low (all expected changes are false negatives)
691        let score = delta_chr_f(original, expected, actual);
692        assert!(score < 20.0);
693    }
694
695    #[test]
696    fn test_delta_chr_f_extra_edit() {
697        // When adding unexpected content
698        let original = "helloworld";
699        let expected = "helloworld"; // no change expected
700        let actual = "helloextraworld"; // added "extra"
701
702        // Then the score should be low (all actual changes are false positives)
703        let score = delta_chr_f(original, expected, actual);
704        assert!(score < 20.0);
705    }
706
707    #[test]
708    fn test_delta_chr_f_no_changes() {
709        let text = "unchanged text";
710        let score = delta_chr_f(text, text, text);
711        assert!((score - 100.0).abs() < 1e-2);
712    }
713
714    #[test]
715    fn test_braces_disbalance() {
716        let text = "let x = { 1 + 2 };";
717        assert_eq!(braces_disbalance(text), 0);
718
719        let text = "let x = { 1 + 2";
720        assert_eq!(braces_disbalance(text), 1);
721
722        let text = "let x = { 1 + 2 )";
723        assert_eq!(braces_disbalance(text), 2);
724    }
725
726    #[test]
727    fn test_extract_changed_lines_from_diff() {
728        let diff = r#"--- a/file.rs
729+++ b/file.rs
730@@ -1,3 +1,3 @@
731 fn main() {
732-    println!("hello");
733+    println!("world");
734 }"#;
735
736        let counts = extract_changed_lines_from_diff(diff);
737        assert_eq!(counts.get("-    println!(\"hello\");"), Some(&1));
738        assert_eq!(counts.get("+    println!(\"world\");"), Some(&1));
739        assert_eq!(counts.len(), 2);
740    }
741
742    #[test]
743    fn test_extract_changed_lines_skips_headers() {
744        let diff = r#"diff --git a/file.rs b/file.rs
745index abc123..def456 100644
746--- a/file.rs
747+++ b/file.rs
748@@ -1,2 +1,2 @@
749-old line
750+new line"#;
751
752        let counts = extract_changed_lines_from_diff(diff);
753        assert_eq!(counts.get("-old line"), Some(&1));
754        assert_eq!(counts.get("+new line"), Some(&1));
755        assert_eq!(counts.len(), 2);
756    }
757
758    #[test]
759    fn test_exact_lines_match_perfect() {
760        let expected = r#"--- a/file.rs
761+++ b/file.rs
762@@ -1,3 +1,3 @@
763-old line 1
764-old line 2
765+new line 1
766+new line 2"#;
767
768        let actual = r#"--- a/file.rs
769+++ b/file.rs
770@@ -1,3 +1,3 @@
771-old line 1
772-old line 2
773+new line 1
774+new line 2"#;
775
776        let metrics = exact_lines_match(expected, actual);
777        assert_eq!(metrics.true_positives, 4);
778        assert_eq!(metrics.false_positives, 0);
779        assert_eq!(metrics.false_negatives, 0);
780        assert!((metrics.precision() - 1.0).abs() < 1e-6);
781        assert!((metrics.recall() - 1.0).abs() < 1e-6);
782        assert!((metrics.f1() - 1.0).abs() < 1e-6);
783    }
784
785    #[test]
786    fn test_exact_lines_match_partial() {
787        let expected = r#"-old line 1
788-old line 2
789+new line 1
790+new line 2"#;
791
792        let actual = r#"-old line 1
793+new line 1
794+extra line"#;
795
796        let metrics = exact_lines_match(expected, actual);
797        // TP: "-old line 1" and "+new line 1" (2)
798        // FP: "+extra line" (1)
799        // FN: "-old line 2" and "+new line 2" (2)
800        assert_eq!(metrics.true_positives, 2);
801        assert_eq!(metrics.false_positives, 1);
802        assert_eq!(metrics.false_negatives, 2);
803    }
804
805    #[test]
806    fn test_exact_lines_match_no_overlap() {
807        let expected = r#"-line a
808+line b"#;
809
810        let actual = r#"-line x
811+line y"#;
812
813        let metrics = exact_lines_match(expected, actual);
814        assert_eq!(metrics.true_positives, 0);
815        assert_eq!(metrics.false_positives, 2);
816        assert_eq!(metrics.false_negatives, 2);
817        assert!((metrics.precision()).abs() < 1e-6);
818        assert!((metrics.recall()).abs() < 1e-6);
819    }
820
821    #[test]
822    fn test_exact_lines_match_duplicate_lines() {
823        let expected = r#"+line a
824+line a
825+line a"#;
826
827        let actual = r#"+line a
828+line a"#;
829
830        let metrics = exact_lines_match(expected, actual);
831        // Expected has 3 "+line a", actual has 2
832        // TP: 2, FN: 1, FP: 0
833        assert_eq!(metrics.true_positives, 2);
834        assert_eq!(metrics.false_positives, 0);
835        assert_eq!(metrics.false_negatives, 1);
836    }
837
838    #[test]
839    fn test_exact_lines_match_empty_patches() {
840        let metrics = exact_lines_match("", "");
841        assert_eq!(metrics.true_positives, 0);
842        assert_eq!(metrics.false_positives, 0);
843        assert_eq!(metrics.false_negatives, 0);
844    }
845
846    #[test]
847    fn test_is_editable_region_correct() {
848        let patch = indoc! {"
849            @@ -1,1 +1,1 @@
850            -context
851            -removed
852            -from the beginning of the file
853            import sys
854            +sys.exit(0)
855
856            "};
857        assert!(!is_editable_region_correct(patch));
858
859        let patch = indoc! {"
860            @@ -1,1 +1,1 @@
861            "};
862        assert!(is_editable_region_correct(patch));
863    }
864
865    #[test]
866    fn test_isolated_whitespace_purely_whitespace_patch() {
867        let patch = indoc! {"
868            @@ -1,3 +1,4 @@
869             fn main() {
870            +
871                 println!(\"hello\");
872             }
873        "};
874        assert!(has_isolated_whitespace_changes(patch, None));
875    }
876
877    #[test]
878    fn test_isolated_whitespace_adjacent_to_real_change() {
879        let patch = indoc! {"
880            @@ -1,3 +1,4 @@
881             fn main() {
882            +
883            +    let x = 1;
884                 println!(\"hello\");
885             }
886        "};
887        assert!(!has_isolated_whitespace_changes(patch, None));
888    }
889
890    #[test]
891    fn test_isolated_whitespace_no_whitespace_changes() {
892        let patch = indoc! {"
893            @@ -1,3 +1,3 @@
894             fn main() {
895            -    println!(\"hello\");
896            +    println!(\"world\");
897             }
898        "};
899        assert!(!has_isolated_whitespace_changes(patch, None));
900    }
901
902    #[test]
903    fn test_isolated_whitespace_deletion() {
904        let patch = indoc! {"
905            @@ -1,4 +1,3 @@
906             fn main() {
907            -
908                 println!(\"hello\");
909             }
910        "};
911        assert!(has_isolated_whitespace_changes(patch, None));
912    }
913
914    #[test]
915    fn test_isolated_whitespace_mixed_groups() {
916        let patch = indoc! {"
917            @@ -1,7 +1,8 @@
918             fn main() {
919            +
920                 let x = 1;
921            -    let y = 2;
922            +    let y = 3;
923
924            +
925                 println!(\"hello\");
926             }
927        "};
928        assert!(has_isolated_whitespace_changes(patch, None));
929    }
930
931    #[test]
932    fn test_isolated_whitespace_empty_patch() {
933        let patch = "";
934        assert!(!has_isolated_whitespace_changes(patch, None));
935    }
936
937    #[test]
938    fn test_isolated_whitespace_skipped_on_cursor_line() {
939        // The addition of a blank line at new-file line 2 should be skipped
940        // because the cursor is on that line.
941        let patch = indoc! {"
942            @@ -1,3 +1,4 @@
943             fn main() {
944            +
945                 println!(\"hello\");
946             }
947        "};
948        // New-file line 2 is the added blank line
949        let cursor = cursor_on_line(2);
950        assert!(!has_isolated_whitespace_changes(patch, Some(&cursor)));
951    }
952
953    #[test]
954    fn test_isolated_whitespace_not_skipped_when_cursor_on_different_line() {
955        // The blank line is at new-file line 2, but the cursor is on line 1.
956        let patch = indoc! {"
957            @@ -1,3 +1,4 @@
958             fn main() {
959            +
960                 println!(\"hello\");
961             }
962        "};
963        let cursor = cursor_on_line(1);
964        assert!(has_isolated_whitespace_changes(patch, Some(&cursor)));
965    }
966
967    #[test]
968    fn test_isolated_whitespace_deletion_not_skipped_by_cursor() {
969        // Deletions don't have a new-file line, so cursor can't suppress them.
970        let patch = indoc! {"
971            @@ -1,4 +1,3 @@
972             fn main() {
973            -
974                 println!(\"hello\");
975             }
976        "};
977        let cursor = cursor_on_line(2);
978        assert!(has_isolated_whitespace_changes(patch, Some(&cursor)));
979    }
980}