Skip to repository content934 lines · 30.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:04:06.256Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
main.rs
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