Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:00:45.332Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

main.rs

934 lines · 30.6 KB · rust
1use std::env;
2use std::fmt::Write as _;
3use std::fs;
4use std::path::Path;
5use std::process;
6
7use edit_prediction_metrics::{
8    ClassificationMetrics, DeltaChrFMetrics, EditableContextCoverage, Excerpt, KeptRateResult,
9    TokenAnnotation, annotate_kept_rate_tokens, braces_disbalance, compute_kept_rate,
10    count_patch_token_changes, delta_chr_f, editable_context_coverage, exact_lines_match,
11    extract_changed_lines_from_diff, has_isolated_whitespace_changes, is_editable_region_correct,
12};
13use serde::Deserialize;
14
15fn main() {
16    if let Err(error) = run() {
17        eprintln!("error: {error}");
18        process::exit(1);
19    }
20}
21
22fn run() -> Result<(), String> {
23    let args: Vec<String> = env::args().skip(1).collect();
24    if args.is_empty() {
25        print_usage();
26        return Err("missing arguments".to_string());
27    }
28
29    let input = CliInput::parse(&args)?;
30    let report = match input {
31        CliInput::Files {
32            base_path,
33            expected_patch_path,
34            actual_patch_path,
35        } => {
36            let base = fs::read_to_string(&base_path)
37                .map_err(|err| format!("failed to read {}: {err}", base_path.display()))?;
38            let expected_patch = fs::read_to_string(&expected_patch_path).map_err(|err| {
39                format!("failed to read {}: {err}", expected_patch_path.display())
40            })?;
41            let actual_patch = fs::read_to_string(&actual_patch_path)
42                .map_err(|err| format!("failed to read {}: {err}", actual_patch_path.display()))?;
43
44            let expected = apply_patch_to_excerpt(&base, &expected_patch, 0, None)?;
45            let actual = apply_patch_to_excerpt(&base, &actual_patch, 0, None)?;
46            let context = [];
47
48            EvaluationReport::new(
49                base,
50                expected_patch,
51                actual_patch,
52                expected,
53                actual,
54                &context,
55            )
56        }
57        CliInput::Json {
58            json_path,
59            prediction_index,
60        } => {
61            let json = fs::read_to_string(&json_path)
62                .map_err(|err| format!("failed to read {}: {err}", json_path.display()))?;
63            let example: JsonExample = serde_json::from_str(&json)
64                .map_err(|err| format!("failed to parse {}: {err}", json_path.display()))?;
65
66            report_from_json_example(example, prediction_index)?
67        }
68    };
69
70    print_report(&report);
71    Ok(())
72}
73
74fn get_context_excerpts(example: &JsonExample) -> Vec<Excerpt> {
75    let mut context = vec![get_cursor_excerpt(example)];
76
77    if let Some(related) = &example.prompt_inputs.related_files {
78        context.extend(related.iter().flat_map(|file| {
79            file.excerpts.iter().map(|excerpt| Excerpt {
80                path: file.path.clone(),
81                row_range: excerpt.row_range.clone(),
82                content: excerpt.text.clone(),
83            })
84        }));
85    }
86
87    context
88}
89
90fn get_cursor_excerpt(example: &JsonExample) -> Excerpt {
91    let content = example.prompt_inputs.cursor_excerpt.clone();
92    let start_row = example.prompt_inputs.excerpt_start_row;
93    let rows = content.lines().count() as u32;
94    let row_range = start_row..start_row + rows;
95    Excerpt {
96        path: example.cursor_path.clone(),
97        row_range,
98        content,
99    }
100}
101
102fn report_from_json_example(
103    example: JsonExample,
104    prediction_index: usize,
105) -> Result<EvaluationReport, String> {
106    let context = get_context_excerpts(&example);
107    let excerpt_start_row = example.prompt_inputs.excerpt_start_row;
108    let cursor_path = example.cursor_path;
109    let base = example.prompt_inputs.cursor_excerpt;
110    let expected_patch = example
111        .expected_patches
112        .into_iter()
113        .next()
114        .ok_or_else(|| "JSON input is missing expected_patches[0]".to_string())?;
115    let actual_patch = if example.predictions.is_empty() {
116        String::new()
117    } else {
118        example
119            .predictions
120            .into_iter()
121            .nth(prediction_index)
122            .ok_or_else(|| format!("JSON input does not contain predictions[{prediction_index}]"))?
123            .actual_patch
124    };
125
126    let expected = apply_patch_to_excerpt(
127        &base,
128        &expected_patch,
129        excerpt_start_row,
130        Some(&cursor_path),
131    )?;
132    let actual =
133        apply_patch_to_excerpt(&base, &actual_patch, excerpt_start_row, Some(&cursor_path))?;
134
135    Ok(EvaluationReport::new(
136        base,
137        expected_patch,
138        actual_patch,
139        expected,
140        actual,
141        &context,
142    ))
143}
144
145fn print_usage() {
146    eprintln!(
147        "Usage:\n  edit_prediction_metrics --base <base.txt> --expected-patch <expected.diff> --actual-patch <actual.diff>\n  edit_prediction_metrics --json <example.json> [--prediction-index <n>]"
148    );
149}
150
151enum CliInput {
152    Files {
153        base_path: std::path::PathBuf,
154        expected_patch_path: std::path::PathBuf,
155        actual_patch_path: std::path::PathBuf,
156    },
157    Json {
158        json_path: std::path::PathBuf,
159        prediction_index: usize,
160    },
161}
162
163impl CliInput {
164    fn parse(args: &[String]) -> Result<Self, String> {
165        let mut base_path = None;
166        let mut expected_patch_path = None;
167        let mut actual_patch_path = None;
168        let mut json_path = None;
169        let mut prediction_index = 0usize;
170
171        let mut index = 0;
172        while index < args.len() {
173            match args[index].as_str() {
174                "--base" => {
175                    index += 1;
176                    base_path = Some(path_arg(args, index, "--base")?);
177                }
178                "--expected-patch" => {
179                    index += 1;
180                    expected_patch_path = Some(path_arg(args, index, "--expected-patch")?);
181                }
182                "--actual-patch" => {
183                    index += 1;
184                    actual_patch_path = Some(path_arg(args, index, "--actual-patch")?);
185                }
186                "--json" => {
187                    index += 1;
188                    json_path = Some(path_arg(args, index, "--json")?);
189                }
190                "--prediction-index" => {
191                    index += 1;
192                    let raw = string_arg(args, index, "--prediction-index")?;
193                    prediction_index = raw.parse::<usize>().map_err(|err| {
194                        format!("invalid value for --prediction-index ({raw}): {err}")
195                    })?;
196                }
197                "--help" | "-h" => {
198                    print_usage();
199                    process::exit(0);
200                }
201                unknown => {
202                    return Err(format!("unrecognized argument: {unknown}"));
203                }
204            }
205            index += 1;
206        }
207
208        if let Some(json_path) = json_path {
209            if base_path.is_some() || expected_patch_path.is_some() || actual_patch_path.is_some() {
210                return Err(
211                    "--json cannot be combined with --base/--expected-patch/--actual-patch"
212                        .to_string(),
213                );
214            }
215            return Ok(CliInput::Json {
216                json_path,
217                prediction_index,
218            });
219        }
220
221        match (base_path, expected_patch_path, actual_patch_path) {
222            (Some(base_path), Some(expected_patch_path), Some(actual_patch_path)) => {
223                Ok(CliInput::Files {
224                    base_path,
225                    expected_patch_path,
226                    actual_patch_path,
227                })
228            }
229            _ => Err(
230                "expected either --json <file> or all of --base, --expected-patch, and --actual-patch"
231                    .to_string(),
232            ),
233        }
234    }
235}
236
237fn path_arg(args: &[String], index: usize, flag: &str) -> Result<std::path::PathBuf, String> {
238    Ok(Path::new(string_arg(args, index, flag)?).to_path_buf())
239}
240
241fn string_arg<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> {
242    args.get(index)
243        .map(|value| value.as_str())
244        .ok_or_else(|| format!("missing value for {flag}"))
245}
246
247#[derive(Debug)]
248struct EvaluationReport {
249    base: String,
250    expected: String,
251    actual: String,
252    kept_rate: KeptRateResult,
253    exact_lines: ClassificationMetrics,
254    delta_chr_f: DeltaChrFMetrics,
255    expected_changed_lines: usize,
256    actual_changed_lines: usize,
257    token_changes: edit_prediction_metrics::TokenChangeCounts,
258    isolated_whitespace_changes: bool,
259    editable_region_correct: bool,
260    expected_braces_disbalance: usize,
261    actual_braces_disbalance: usize,
262    editable_context_coverage: EditableContextCoverage,
263}
264
265impl EvaluationReport {
266    fn new(
267        base: String,
268        expected_patch: String,
269        actual_patch: String,
270        expected: String,
271        actual: String,
272        context: &[Excerpt],
273    ) -> Self {
274        let kept_rate = compute_kept_rate(&base, &actual, &expected);
275        let exact_lines = exact_lines_match(&expected_patch, &actual_patch);
276        let delta_chr_f = delta_chr_f(&base, &expected, &actual);
277        let expected_changed_lines = extract_changed_lines_from_diff(&expected_patch)
278            .values()
279            .sum();
280        let actual_changed_lines = extract_changed_lines_from_diff(&actual_patch)
281            .values()
282            .sum();
283        let token_changes = count_patch_token_changes(&actual_patch);
284        let isolated_whitespace_changes = has_isolated_whitespace_changes(&actual_patch, None);
285        let editable_region_correct = is_editable_region_correct(&actual_patch);
286        let expected_braces_disbalance = braces_disbalance(&expected);
287        let actual_braces_disbalance = braces_disbalance(&actual);
288        let editable_context_coverage = editable_context_coverage(&expected_patch, context);
289
290        Self {
291            base,
292            expected,
293            actual,
294            kept_rate,
295            exact_lines,
296            delta_chr_f,
297            expected_changed_lines,
298            actual_changed_lines,
299            token_changes,
300            isolated_whitespace_changes,
301            editable_region_correct,
302            expected_braces_disbalance,
303            actual_braces_disbalance,
304            editable_context_coverage,
305        }
306    }
307}
308
309fn print_report(report: &EvaluationReport) {
310    println!("Metrics");
311    println!("=======");
312    println!("kept_rate: {:.6}", report.kept_rate.kept_rate);
313    println!("kept_rate_recall: {:.6}", report.kept_rate.recall_rate);
314    println!("delta_chr_f: {:.6}", report.delta_chr_f.score);
315    println!("delta_chr_f_precision: {:.6}", report.delta_chr_f.precision);
316    println!("delta_chr_f_recall: {:.6}", report.delta_chr_f.recall);
317    println!("delta_chr_f_beta: {:.6}", report.delta_chr_f.beta);
318    println!();
319
320    println!("Exact line match");
321    println!("----------------");
322    println!("true_positives: {}", report.exact_lines.true_positives);
323    println!("false_positives: {}", report.exact_lines.false_positives);
324    println!("false_negatives: {}", report.exact_lines.false_negatives);
325    println!("precision: {:.6}", report.exact_lines.precision());
326    println!("recall: {:.6}", report.exact_lines.recall());
327    println!("f1: {:.6}", report.exact_lines.f1());
328    println!("expected_changed_lines: {}", report.expected_changed_lines);
329    println!("actual_changed_lines: {}", report.actual_changed_lines);
330    println!();
331
332    println!("Patch structure");
333    println!("---------------");
334    println!("inserted_tokens: {}", report.token_changes.inserted_tokens);
335    println!("deleted_tokens: {}", report.token_changes.deleted_tokens);
336    println!(
337        "isolated_whitespace_changes: {}",
338        report.isolated_whitespace_changes
339    );
340    println!(
341        "editable_region_correct: {}",
342        report.editable_region_correct
343    );
344    println!();
345
346    println!("Final text checks");
347    println!("-----------------");
348    println!(
349        "expected_braces_disbalance: {}",
350        report.expected_braces_disbalance
351    );
352    println!(
353        "actual_braces_disbalance: {}",
354        report.actual_braces_disbalance
355    );
356    println!();
357
358    println!("Kept-rate breakdown");
359    println!("-------------------");
360    println!(
361        "candidate_new_chars: {}",
362        report.kept_rate.candidate_new_chars
363    );
364    println!(
365        "reference_new_chars: {}",
366        report.kept_rate.reference_new_chars
367    );
368    println!(
369        "candidate_deleted_chars: {}",
370        report.kept_rate.candidate_deleted_chars
371    );
372    println!(
373        "reference_deleted_chars: {}",
374        report.kept_rate.reference_deleted_chars
375    );
376    println!("kept_chars: {}", report.kept_rate.kept_chars);
377    println!(
378        "correctly_deleted_chars: {}",
379        report.kept_rate.correctly_deleted_chars
380    );
381    println!("discarded_chars: {}", report.kept_rate.discarded_chars);
382    println!("context_chars: {}", report.kept_rate.context_chars);
383    println!();
384
385    print_kept_rate_explanation(&report.base, &report.actual, &report.expected);
386
387    println!("Jumps metrics");
388    println!("-------------");
389    println!(
390        "Editable context lines: P={}%, R={}%, F1={}% (tp: {}, fp: {}, fn: {})",
391        (report.editable_context_coverage.lines_precision * 100.0).round(),
392        (report.editable_context_coverage.lines_recall * 100.0).round(),
393        (report.editable_context_coverage.lines_f1 * 100.0).round(),
394        report.editable_context_coverage.lines_tp,
395        report.editable_context_coverage.lines_fp,
396        report.editable_context_coverage.lines_fn
397    );
398    println!(
399        "Editable context files: P={}%, R={}%, F1={}% (tp: {}, fp: {}, fn: {})",
400        (report.editable_context_coverage.files_precision * 100.0).round(),
401        (report.editable_context_coverage.files_recall * 100.0).round(),
402        (report.editable_context_coverage.files_f1 * 100.0).round(),
403        report.editable_context_coverage.files_tp,
404        report.editable_context_coverage.files_fp,
405        report.editable_context_coverage.files_fn
406    );
407}
408
409fn print_kept_rate_explanation(base: &str, actual: &str, expected: &str) {
410    println!("Kept-rate explanation");
411    println!("---------------------");
412    println!("Legend: context = default, kept = green background, discarded = red background");
413    println!();
414
415    let annotated = annotate_kept_rate_tokens(base, actual, expected);
416    println!("Actual final text with token annotations:");
417    println!("{}", render_annotated_tokens(&annotated));
418    println!();
419}
420
421fn render_annotated_tokens(tokens: &[edit_prediction_metrics::AnnotatedToken]) -> String {
422    const RESET: &str = "\x1b[0m";
423    const KEPT_STYLE: &str = "\x1b[30;42m";
424    const DISCARDED_STYLE: &str = "\x1b[30;41m";
425
426    let mut rendered = String::new();
427    for token in tokens {
428        let style = match token.annotation {
429            TokenAnnotation::Context => "",
430            TokenAnnotation::Kept => KEPT_STYLE,
431            TokenAnnotation::Discarded => DISCARDED_STYLE,
432        };
433
434        if style.is_empty() {
435            rendered.push_str(&visualize_whitespace(&token.token));
436        } else {
437            rendered.push_str(style);
438            rendered.push_str(&visualize_whitespace(&token.token));
439            rendered.push_str(RESET);
440        }
441    }
442    rendered
443}
444
445fn visualize_whitespace(token: &str) -> String {
446    let mut rendered = String::new();
447    for ch in token.chars() {
448        match ch {
449            ' ' => rendered.push('·'),
450            '\t' => rendered.push('⇥'),
451            '\n' => rendered.push_str("↵\n"),
452            _ => rendered.push(ch),
453        }
454    }
455    rendered
456}
457
458#[derive(Debug, Deserialize)]
459struct JsonExample {
460    prompt_inputs: PromptInputs,
461    cursor_path: String,
462    expected_patches: Vec<String>,
463    #[serde(default)]
464    predictions: Vec<Prediction>,
465}
466
467#[derive(Debug, Deserialize)]
468struct PromptInputs {
469    cursor_excerpt: String,
470    excerpt_start_row: u32,
471    pub related_files: Option<Vec<RelatedFile>>,
472}
473
474#[derive(Clone, Debug, PartialEq, Hash, Deserialize)]
475pub struct RelatedFile {
476    pub path: String,
477    pub max_row: u32,
478    pub excerpts: Vec<RelatedExcerpt>,
479}
480
481#[derive(Clone, Debug, PartialEq, Hash, Deserialize)]
482pub struct RelatedExcerpt {
483    pub row_range: std::ops::Range<u32>,
484    pub text: String,
485}
486
487#[derive(Debug, Deserialize)]
488struct Prediction {
489    actual_patch: String,
490}
491
492#[derive(Debug, Clone)]
493struct ParsedHunk {
494    old_start: u32,
495    filename: Option<String>,
496    lines: Vec<HunkLine>,
497}
498
499#[derive(Debug, Clone)]
500enum HunkLine {
501    Context(String),
502    Addition(String),
503    Deletion(String),
504}
505
506fn apply_patch_to_excerpt(
507    base: &str,
508    patch: &str,
509    excerpt_start_row: u32,
510    target_path: Option<&str>,
511) -> Result<String, String> {
512    let hunks = parse_diff_hunks(patch);
513    let hunks = if let Some(target_path) = target_path {
514        hunks
515            .into_iter()
516            .filter(|hunk| match hunk.filename.as_deref() {
517                Some(filename) => filename == target_path,
518                None => true,
519            })
520            .collect::<Vec<_>>()
521    } else {
522        hunks
523    };
524
525    let result = try_apply_hunks(base, &hunks, excerpt_start_row);
526
527    // Predicted patches may use excerpt-relative line numbers instead of
528    // file-global ones. When all hunks fall outside the excerpt window the
529    // result is identical to the base text. Retry with a zero offset so the
530    // line numbers are interpreted relative to the excerpt.
531    if excerpt_start_row > 0 && !hunks.is_empty() {
532        let should_retry = match &result {
533            Ok(text) => text == base,
534            Err(_) => true,
535        };
536
537        if should_retry {
538            let fallback = try_apply_hunks(base, &hunks, 0);
539            if matches!(&fallback, Ok(text) if text != base) {
540                return fallback;
541            }
542        }
543    }
544
545    result
546}
547
548fn try_apply_hunks(
549    base: &str,
550    hunks: &[ParsedHunk],
551    excerpt_start_row: u32,
552) -> Result<String, String> {
553    let base_has_trailing_newline = base.ends_with('\n');
554    let mut lines = split_preserving_final_empty_line(base);
555    let original_line_count = lines.len() as u32;
556
557    let excerpt_end_row = excerpt_start_row + original_line_count;
558    let mut line_delta: i64 = 0;
559
560    for hunk in hunks {
561        let filtered = match filter_hunk_to_excerpt(hunk, excerpt_start_row, excerpt_end_row) {
562            Some(filtered) => filtered,
563            None => continue,
564        };
565
566        let local_start = filtered.old_start.saturating_sub(excerpt_start_row) as i64 + line_delta;
567        if local_start < 0 {
568            return Err(format!(
569                "patch application moved before excerpt start at source row {}",
570                filtered.old_start
571            ));
572        }
573        let local_start = local_start as usize;
574
575        if local_start > lines.len() {
576            return Err(format!(
577                "patch application starts past excerpt end at local line {}",
578                local_start + 1
579            ));
580        }
581
582        let old_len = filtered
583            .lines
584            .iter()
585            .filter(|line| !matches!(line, HunkLine::Addition(_)))
586            .count();
587        let new_len = filtered
588            .lines
589            .iter()
590            .filter(|line| !matches!(line, HunkLine::Deletion(_)))
591            .count();
592
593        let old_segment: Vec<&str> = filtered
594            .lines
595            .iter()
596            .filter_map(|line| match line {
597                HunkLine::Context(text) | HunkLine::Deletion(text) => Some(text.as_str()),
598                HunkLine::Addition(_) => None,
599            })
600            .collect();
601
602        let new_segment: Vec<String> = filtered
603            .lines
604            .iter()
605            .filter_map(|line| match line {
606                HunkLine::Context(text) | HunkLine::Addition(text) => Some(text.clone()),
607                HunkLine::Deletion(_) => None,
608            })
609            .collect();
610
611        if local_start + old_len > lines.len() {
612            return Err(format!(
613                "patch application exceeds excerpt bounds near source row {}",
614                filtered.old_start
615            ));
616        }
617
618        let current_segment: Vec<&str> = lines[local_start..local_start + old_len]
619            .iter()
620            .map(String::as_str)
621            .collect();
622
623        if current_segment != old_segment {
624            let mut details = String::new();
625            let _ = write!(
626                details,
627                "patch context mismatch near source row {}: expected {:?}, found {:?}",
628                filtered.old_start, old_segment, current_segment
629            );
630            return Err(details);
631        }
632
633        lines.splice(local_start..local_start + old_len, new_segment);
634        line_delta += new_len as i64 - old_len as i64;
635    }
636
637    Ok(join_lines(&lines, base_has_trailing_newline))
638}
639
640fn split_preserving_final_empty_line(text: &str) -> Vec<String> {
641    let mut lines: Vec<String> = text.lines().map(ToString::to_string).collect();
642    if text.ends_with('\n') {
643        if lines.last().is_some_and(|line| !line.is_empty()) || lines.is_empty() {
644            lines.push(String::new());
645        }
646    }
647    lines
648}
649
650fn join_lines(lines: &[String], had_trailing_newline: bool) -> String {
651    if lines.is_empty() {
652        return String::new();
653    }
654
655    let mut joined = lines.join("\n");
656    if had_trailing_newline && !joined.ends_with('\n') {
657        joined.push('\n');
658    }
659    if !had_trailing_newline && joined.ends_with('\n') {
660        joined.pop();
661    }
662    joined
663}
664
665fn filter_hunk_to_excerpt(
666    hunk: &ParsedHunk,
667    excerpt_start_row: u32,
668    excerpt_end_row: u32,
669) -> Option<ParsedHunk> {
670    let mut filtered_lines = Vec::new();
671    let mut current_old_row = hunk.old_start.saturating_sub(1);
672    let mut filtered_old_start = None;
673    let mut has_overlap = false;
674
675    for line in &hunk.lines {
676        match line {
677            HunkLine::Context(text) => {
678                let in_excerpt =
679                    current_old_row >= excerpt_start_row && current_old_row < excerpt_end_row;
680                if in_excerpt {
681                    filtered_old_start.get_or_insert(current_old_row);
682                    filtered_lines.push(HunkLine::Context(text.clone()));
683                    has_overlap = true;
684                }
685                current_old_row += 1;
686            }
687            HunkLine::Deletion(text) => {
688                let in_excerpt =
689                    current_old_row >= excerpt_start_row && current_old_row < excerpt_end_row;
690                if in_excerpt {
691                    filtered_old_start.get_or_insert(current_old_row);
692                    filtered_lines.push(HunkLine::Deletion(text.clone()));
693                    has_overlap = true;
694                }
695                current_old_row += 1;
696            }
697            HunkLine::Addition(text) => {
698                let insertion_in_excerpt =
699                    current_old_row >= excerpt_start_row && current_old_row <= excerpt_end_row;
700                if insertion_in_excerpt {
701                    filtered_old_start.get_or_insert(current_old_row);
702                    filtered_lines.push(HunkLine::Addition(text.clone()));
703                    has_overlap = true;
704                }
705            }
706        }
707    }
708
709    if !has_overlap {
710        return None;
711    }
712
713    Some(ParsedHunk {
714        old_start: filtered_old_start.unwrap_or(excerpt_start_row),
715        filename: hunk.filename.clone(),
716        lines: filtered_lines,
717    })
718}
719
720fn parse_diff_hunks(diff: &str) -> Vec<ParsedHunk> {
721    let mut hunks = Vec::new();
722    let mut current_hunk: Option<ParsedHunk> = None;
723    let mut current_filename = None;
724
725    for line in diff.lines() {
726        if let Some(filename) = parse_diff_filename(line) {
727            current_filename = Some(filename);
728            continue;
729        }
730
731        if let Some((old_start, old_count, _new_start, _new_count)) = parse_hunk_header(line) {
732            if let Some(hunk) = current_hunk.take() {
733                hunks.push(hunk);
734            }
735            let _ = old_count;
736            current_hunk = Some(ParsedHunk {
737                old_start,
738                filename: current_filename.clone(),
739                lines: Vec::new(),
740            });
741            continue;
742        }
743
744        let Some(hunk) = current_hunk.as_mut() else {
745            continue;
746        };
747
748        if let Some(text) = line.strip_prefix('+') {
749            if !line.starts_with("+++") {
750                hunk.lines.push(HunkLine::Addition(text.to_string()));
751            }
752        } else if let Some(text) = line.strip_prefix('-') {
753            if !line.starts_with("---") {
754                hunk.lines.push(HunkLine::Deletion(text.to_string()));
755            }
756        } else if let Some(text) = line.strip_prefix(' ') {
757            hunk.lines.push(HunkLine::Context(text.to_string()));
758        } else if line.is_empty() {
759            hunk.lines.push(HunkLine::Context(String::new()));
760        }
761    }
762
763    if let Some(hunk) = current_hunk {
764        hunks.push(hunk);
765    }
766
767    hunks
768}
769
770fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32, u32)> {
771    let line = line.strip_prefix("@@ -")?;
772    let (old_part, rest) = line.split_once(' ')?;
773    let rest = rest.strip_prefix('+')?;
774    let (new_part, _) = rest.split_once(" @@")?;
775
776    let (old_start, old_count) = parse_hunk_range(old_part)?;
777    let (new_start, new_count) = parse_hunk_range(new_part)?;
778    Some((old_start, old_count, new_start, new_count))
779}
780
781fn parse_hunk_range(part: &str) -> Option<(u32, u32)> {
782    if let Some((start, count)) = part.split_once(',') {
783        Some((start.parse().ok()?, count.parse().ok()?))
784    } else {
785        Some((part.parse().ok()?, 1))
786    }
787}
788
789fn parse_diff_filename(line: &str) -> Option<String> {
790    let path = line
791        .strip_prefix("--- ")
792        .or_else(|| line.strip_prefix("+++ "))?;
793    normalize_diff_path(path)
794}
795
796fn normalize_diff_path(path: &str) -> Option<String> {
797    let path = path.trim();
798    let path = path
799        .strip_prefix("a/")
800        .or_else(|| path.strip_prefix("b/"))
801        .unwrap_or(path);
802
803    if path == "/dev/null" {
804        None
805    } else {
806        Some(path.to_string())
807    }
808}
809
810#[cfg(test)]
811mod tests {
812    use super::*;
813
814    #[test]
815    fn applies_patch_in_file_mode() {
816        let base = "fn main() {\n    println!(\"hello\");\n}\n";
817        let patch = "@@ -1,3 +1,3 @@\n fn main() {\n-    println!(\"hello\");\n+    println!(\"world\");\n }\n";
818
819        let actual = apply_patch_to_excerpt(base, patch, 0, None).unwrap();
820        assert_eq!(actual, "fn main() {\n    println!(\"world\");\n}\n");
821    }
822
823    #[test]
824    fn applies_patch_in_json_excerpt_mode() {
825        let base = "b\nc\nd\n";
826        let patch = "@@ -2,2 +2,2 @@\n-b\n-c\n+x\n+y\n";
827
828        let actual = apply_patch_to_excerpt(base, patch, 1, None).unwrap();
829        assert_eq!(actual, "x\ny\nd\n");
830    }
831
832    #[test]
833    fn applies_patch_with_excerpt_relative_line_numbers() {
834        let base = "a\nb\nc\nd\n";
835        // Patch uses excerpt-relative line numbers (line 2 of excerpt)
836        // even though the excerpt starts at file row 100.
837        let patch = "@@ -2,2 +2,2 @@\n-b\n-c\n+x\n+y\n";
838
839        let actual = apply_patch_to_excerpt(base, patch, 100, None).unwrap();
840        assert_eq!(actual, "a\nx\ny\nd\n");
841    }
842
843    #[test]
844    fn prefers_file_global_line_numbers_over_excerpt_relative() {
845        let base = "a\nb\nc\n";
846        // Patch uses file-global line numbers: excerpt starts at row 5,
847        // hunk targets line 6 (1-based) = row 5 (0-based) = first line.
848        let patch = "@@ -6,2 +6,2 @@\n-a\n-b\n+x\n+y\n";
849
850        let actual = apply_patch_to_excerpt(base, patch, 5, None).unwrap();
851        assert_eq!(actual, "x\ny\nc\n");
852    }
853
854    #[test]
855    fn json_patch_application_ignores_unrelated_file_hunks() {
856        let base = "first\nsecond\nthird\n";
857        let patch = "--- a/src/other.rs\n+++ b/src/other.rs\n@@ -2,1 +2,1 @@\n-second\n+changed\n";
858
859        let actual = apply_patch_to_excerpt(base, patch, 0, Some("src/main.rs")).unwrap();
860        assert_eq!(actual, base);
861    }
862
863    #[test]
864    fn json_patch_application_applies_matching_file_hunks() {
865        let base = "first\nsecond\nthird\n";
866        let patch = "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -2,1 +2,1 @@\n-second\n+changed\n";
867
868        let actual = apply_patch_to_excerpt(base, patch, 0, Some("src/main.rs")).unwrap();
869        assert_eq!(actual, "first\nchanged\nthird\n");
870    }
871
872    #[test]
873    fn json_patch_application_applies_headerless_hunks() {
874        let base = "first\nsecond\nthird\n";
875        let patch = "@@ -2,1 +2,1 @@\n-second\n+changed\n";
876
877        let actual = apply_patch_to_excerpt(base, patch, 0, Some("src/main.rs")).unwrap();
878        assert_eq!(actual, "first\nchanged\nthird\n");
879    }
880
881    fn json_example(predictions: Option<&str>) -> String {
882        let predictions = predictions
883            .map(|predictions| {
884                format!(
885                    r#",
886    "predictions": {predictions}"#
887                )
888            })
889            .unwrap_or_default();
890
891        format!(
892            r#"{{
893    "prompt_inputs": {{
894        "cursor_excerpt": "first\nsecond\nthird\n",
895        "excerpt_start_row": 0
896    }},
897    "cursor_path": "src/main.rs",
898    "expected_patches": [
899        "--- a/src/main.rs\n+++ b/src/main.rs\n@@ -2,1 +2,1 @@\n-second\n+changed\n"
900    ]{predictions}
901}}"#
902        )
903    }
904
905    fn report_from_json(predictions: Option<&str>) -> EvaluationReport {
906        let example = serde_json::from_str(&json_example(predictions)).unwrap();
907        report_from_json_example(example, 0).unwrap()
908    }
909
910    #[test]
911    fn json_report_with_missing_predictions_uses_expected_patch_for_context_coverage() {
912        let report = report_from_json(None);
913
914        assert_eq!(report.actual, "first\nsecond\nthird\n");
915        assert_eq!(report.actual_changed_lines, 0);
916        assert_eq!(
917            report.editable_context_coverage,
918            EditableContextCoverage::new(3, 0, 0, 1, 0, 0)
919        );
920    }
921
922    #[test]
923    fn json_report_with_empty_predictions_uses_expected_patch_for_context_coverage() {
924        let report = report_from_json(Some("[]"));
925
926        assert_eq!(report.actual, "first\nsecond\nthird\n");
927        assert_eq!(report.actual_changed_lines, 0);
928        assert_eq!(
929            report.editable_context_coverage,
930            EditableContextCoverage::new(3, 0, 0, 1, 0, 0)
931        );
932    }
933}
934
Served at tenant.openagents/omega Member data and write actions are omitted.