Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:58:05.696Z 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

split_commit.rs

2611 lines · 85.5 KB · rust
1//! `ep split-commit` implementation.
2//!
3//! This command generates a single evaluation example JSON object from a
4//! chronologically-ordered unified diff (a "commit").
5//!
6//! TODO: Port Python code to generate chronologically-ordered commits
7use crate::FailedHandling;
8use crate::reorder_patch::{
9    EditLocation, Patch, PatchLine, edit_locations, extract_edits, locate_edited_line,
10};
11use crate::word_diff::tokenize;
12
13/// Find the largest valid UTF-8 char boundary at or before `index` in `s`.
14fn floor_char_boundary(s: &str, index: usize) -> usize {
15    if index >= s.len() {
16        s.len()
17    } else if s.is_char_boundary(index) {
18        index
19    } else {
20        // Find the nearest valid character boundary at or before index
21        (0..index)
22            .rev()
23            .find(|&i| s.is_char_boundary(i))
24            .unwrap_or(0)
25    }
26}
27use anyhow::{Context as _, Result};
28use clap::Args;
29use edit_prediction::example_spec::ExampleSpec;
30use rand::Rng;
31use rand::SeedableRng;
32use rand::seq::SliceRandom;
33use serde::Deserialize;
34use similar::{DiffTag, TextDiff};
35use std::collections::BTreeSet;
36use std::fs;
37use std::io::{self, Write};
38use std::path::Path;
39use std::path::PathBuf;
40
41const MAX_SPLIT_POINT_SAMPLING_ATTEMPTS: usize = 10;
42const SAME_FILE_NEAR_LINE_THRESHOLD: usize = 30;
43
44/// A commit has no split point matching the requested kind. This is an
45/// expected outcome when filtering by kind, so such commits are skipped
46/// rather than treated as failures.
47#[derive(Debug)]
48pub struct NoMatchingSplitPointError {
49    kind: SplitPointKind,
50}
51
52impl std::fmt::Display for NoMatchingSplitPointError {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        write!(f, "no split point found matching {}", self.kind)
55    }
56}
57
58impl std::error::Error for NoMatchingSplitPointError {}
59
60/// `ep split-commit` CLI args.
61#[derive(Debug, Args, Clone)]
62pub struct SplitCommitArgs {
63    /// Split point (float 0.0-1.0 for fraction, integer for index, or one of: fim, same-file-near, same-file-far, cross-file; append :<index-or-fraction> to validate a specific split)
64    #[arg(long, short = 's')]
65    pub split_point: Option<String>,
66
67    /// Random seed for reproducibility
68    #[arg(long)]
69    pub seed: Option<u64>,
70
71    /// Pretty-print JSON output
72    #[arg(long, short = 'p')]
73    pub pretty: bool,
74
75    /// Number of samples to generate per commit (samples random split points)
76    #[arg(long, short = 'n')]
77    pub num_samples: Option<usize>,
78}
79
80/// Input format for annotated commits (JSON Lines).
81#[derive(Debug, Clone, Deserialize)]
82#[allow(dead_code)]
83pub struct AnnotatedCommit {
84    /// Repository path (e.g., "repos/omega")
85    pub repo: String,
86    /// Repository URL (e.g., "https://github.com/OpenAgentsInc/omega")
87    pub repo_url: String,
88    /// Commit SHA
89    pub commit_sha: String,
90    /// Chronologically reordered commit diff
91    pub reordered_commit: String,
92    /// Original commit diff
93    pub original_commit: String,
94    /// Whether diff stats match between original and reordered
95    pub diff_stats_match: bool,
96}
97
98/// Cursor position in a file.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct CursorPosition {
101    pub file: String,
102    pub line: usize,
103    pub column: usize,
104    pub line_length: usize,
105}
106
107impl std::fmt::Display for CursorPosition {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        write!(f, "{}:{}:{}", self.file, self.line, self.column)
110    }
111}
112
113/// Represents a split commit with source and target patches.
114#[derive(Debug, Clone)]
115pub struct SplitCommit {
116    pub source_patch: String,
117    pub target_patch: String,
118}
119
120/// Split point specification for evaluation generation.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum SplitPointKind {
123    Fim,
124    SameFileNear,
125    SameFileFar,
126    CrossFile,
127}
128
129impl std::fmt::Display for SplitPointKind {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            SplitPointKind::Fim => write!(f, "fim"),
133            SplitPointKind::SameFileNear => write!(f, "same-file-near"),
134            SplitPointKind::SameFileFar => write!(f, "same-file-far"),
135            SplitPointKind::CrossFile => write!(f, "cross-file"),
136        }
137    }
138}
139
140impl std::str::FromStr for SplitPointKind {
141    type Err = anyhow::Error;
142
143    fn from_str(value: &str) -> Result<Self> {
144        match value {
145            "fim" => Ok(Self::Fim),
146            "same-file-near" => Ok(Self::SameFileNear),
147            "same-file-far" => Ok(Self::SameFileFar),
148            "cross-file" => Ok(Self::CrossFile),
149            _ => anyhow::bail!(
150                "invalid split point kind '{value}' (expected fim, same-file-near, same-file-far, or cross-file)"
151            ),
152        }
153    }
154}
155
156#[derive(Debug, Clone, PartialEq)]
157pub enum SplitPoint {
158    /// Fraction of total edits (0.0 to 1.0)
159    Fraction(f64),
160    /// Absolute index
161    Index(usize),
162    /// Random split point matching the requested kind.
163    Kind(SplitPointKind),
164    /// Explicit split point that must match the requested kind.
165    KindWithSplit {
166        kind: SplitPointKind,
167        split_point: SplitPointValue,
168    },
169}
170
171#[derive(Debug, Clone, PartialEq)]
172pub enum SplitPointValue {
173    Fraction(f64),
174    Index(usize),
175}
176
177fn parse_split_point_value(value: &str) -> Result<SplitPointValue> {
178    if value.contains('.') {
179        value
180            .parse::<f64>()
181            .map(SplitPointValue::Fraction)
182            .with_context(|| format!("invalid split point fraction '{value}'"))
183    } else {
184        value
185            .parse::<usize>()
186            .map(SplitPointValue::Index)
187            .with_context(|| format!("invalid split point index '{value}'"))
188    }
189}
190
191fn parse_split_point(value: &str) -> Result<SplitPoint> {
192    if let Some((kind, split_point)) = value.split_once(':') {
193        let kind = kind.parse::<SplitPointKind>()?;
194        anyhow::ensure!(
195            !split_point.is_empty(),
196            "missing split point after kind '{kind}:'"
197        );
198        return Ok(SplitPoint::KindWithSplit {
199            kind,
200            split_point: parse_split_point_value(split_point)?,
201        });
202    }
203
204    if let Ok(kind) = value.parse::<SplitPointKind>() {
205        return Ok(SplitPoint::Kind(kind));
206    }
207
208    match parse_split_point_value(value)? {
209        SplitPointValue::Fraction(value) => Ok(SplitPoint::Fraction(value)),
210        SplitPointValue::Index(value) => Ok(SplitPoint::Index(value)),
211    }
212}
213
214fn is_service_file(path: &str) -> bool {
215    let path = path.trim();
216    let path = path
217        .strip_prefix("a/")
218        .or_else(|| path.strip_prefix("b/"))
219        .unwrap_or(path)
220        .trim_start_matches("./");
221
222    if path.is_empty() || path == "/dev/null" {
223        return true;
224    }
225
226    let file_name = path.rsplit('/').next().unwrap_or(path);
227    if matches!(
228        file_name,
229        "package.json"
230            | "package-lock.json"
231            | "pnpm-lock.yaml"
232            | "Cargo.lock"
233            | "yarn.lock"
234            | "bun.lock"
235            | "bun.lockb"
236            | "go.sum"
237            | "composer.lock"
238            | "Gemfile.lock"
239            | "Pipfile.lock"
240            | "poetry.lock"
241            | "uv.lock"
242            | ".gitlab-ci.yml"
243            | ".travis.yml"
244            | "azure-pipelines.yml"
245            | "Jenkinsfile"
246    ) {
247        return true;
248    }
249
250    if file_name.ends_with(".min.js")
251        || file_name.ends_with(".bundle.js")
252        || file_name.contains(".generated.")
253        || file_name.ends_with(".pb.go")
254    {
255        return true;
256    }
257
258    if path == ".github/workflows"
259        || path.starts_with(".github/workflows/")
260        || path == ".circleci"
261        || path.starts_with(".circleci/")
262    {
263        return true;
264    }
265
266    path.split('/').any(|component| {
267        matches!(
268            component,
269            "dist" | "build" | "coverage" | "node_modules" | "vendor"
270        )
271    })
272}
273
274fn edit_starts_on_service_file(patch: &Patch, split_pos: usize) -> bool {
275    locate_edited_line(patch, split_pos as isize)
276        .is_some_and(|edit_location| is_service_file(&edit_location.filename))
277}
278
279fn has_submodule_gitlink_hunk(commit: &str) -> bool {
280    commit.lines().any(line_indicates_submodule_gitlink)
281}
282
283fn line_indicates_submodule_gitlink(line: &str) -> bool {
284    let line = line.trim();
285
286    matches!(
287        line,
288        "new file mode 160000" | "deleted file mode 160000" | "old mode 160000" | "new mode 160000"
289    ) || line
290        .strip_prefix("index ")
291        .and_then(|line| line.split_whitespace().last())
292        .is_some_and(|mode| mode == "160000")
293        || line
294            .strip_prefix('+')
295            .or_else(|| line.strip_prefix('-'))
296            .is_some_and(|line| line.starts_with("Subproject commit "))
297}
298
299fn sample_split_point(patch: &Patch, rng: &mut dyn rand::RngCore) -> usize {
300    let stats = patch.stats();
301    let num_edits = stats.added + stats.removed;
302    if num_edits == 0 {
303        return 0;
304    }
305
306    let mut split = rng.random_range(1..=num_edits);
307    for _ in 1..MAX_SPLIT_POINT_SAMPLING_ATTEMPTS {
308        if !edit_starts_on_service_file(patch, split) {
309            break;
310        }
311        split = rng.random_range(1..=num_edits);
312    }
313
314    split
315}
316
317fn resolve_split_point_value(split_point: SplitPointValue, num_edits: usize) -> usize {
318    match split_point {
319        SplitPointValue::Fraction(fraction) => {
320            let split = (fraction * num_edits as f64).floor() as usize;
321            split.min(num_edits)
322        }
323        SplitPointValue::Index(index) => index.min(num_edits),
324    }
325}
326
327#[derive(Debug, Clone)]
328struct GeneratedSplitCommit {
329    split: usize,
330    split_commit: SplitCommit,
331    cursor: CursorPosition,
332    cursor_from_human_edit: bool,
333}
334
335fn generate_split_commit_at_split(
336    patch: &Patch,
337    split: usize,
338    rng: &mut dyn rand::RngCore,
339) -> Result<GeneratedSplitCommit> {
340    let (prefix, suffix) = split_ordered_patch(patch, split);
341
342    let mut split_commit = SplitCommit {
343        source_patch: prefix,
344        target_patch: suffix,
345    };
346
347    let human_edit_seed = rng.random_range(1..=10000u64);
348    let (src_patch, tgt_patch, cursor_opt) = imitate_human_edits(
349        &split_commit.source_patch,
350        &split_commit.target_patch,
351        human_edit_seed,
352    );
353    split_commit.source_patch = src_patch;
354    split_commit.target_patch = tgt_patch;
355
356    let cursor_from_human_edit = cursor_opt.is_some();
357    let cursor = match cursor_opt {
358        Some(cursor) => cursor,
359        None => sample_cursor_position(&split_commit, rng)
360            .context("failed to sample cursor position")?,
361    };
362
363    Ok(GeneratedSplitCommit {
364        split,
365        split_commit,
366        cursor,
367        cursor_from_human_edit,
368    })
369}
370
371fn classify_generated_split_commit(
372    generated_split_commit: &GeneratedSplitCommit,
373) -> Option<SplitPointKind> {
374    let target_patch = Patch::parse_unified_diff(&generated_split_commit.split_commit.target_patch);
375    let next_edit = locate_edited_line(&target_patch, 0)?;
376
377    if next_edit.filename != generated_split_commit.cursor.file {
378        return Some(SplitPointKind::CrossFile);
379    }
380
381    if generated_split_commit.cursor_from_human_edit
382        && next_edit.target_line_number == generated_split_commit.cursor.line
383    {
384        return Some(SplitPointKind::Fim);
385    }
386
387    let line_distance = next_edit
388        .target_line_number
389        .abs_diff(generated_split_commit.cursor.line);
390    if line_distance <= SAME_FILE_NEAR_LINE_THRESHOLD {
391        Some(SplitPointKind::SameFileNear)
392    } else {
393        Some(SplitPointKind::SameFileFar)
394    }
395}
396
397/// Cheap necessary condition for a split to be classifiable as `kind`,
398/// computed from the full patch without generating the split.
399///
400/// The cursor ends up either at the first target edit (or, via
401/// `imitate_human_edits`, on its line), or at the last source edit. So the
402/// edits adjacent to the split bound what classifications are reachable.
403/// Line numbers here are in full-patch coordinates, which can drift slightly
404/// from split-patch coordinates, so this is a heuristic pre-filter; the final
405/// classification is always verified on the generated split.
406fn split_can_match_kind(
407    edit_locations: &[EditLocation],
408    split: usize,
409    kind: SplitPointKind,
410) -> bool {
411    let (Some(previous_edit), Some(next_edit)) = (
412        split.checked_sub(1).and_then(|i| edit_locations.get(i)),
413        edit_locations.get(split),
414    ) else {
415        return false;
416    };
417
418    match kind {
419        SplitPointKind::Fim => matches!(next_edit.patch_line, PatchLine::Addition(_)),
420        SplitPointKind::SameFileNear => true,
421        SplitPointKind::SameFileFar => {
422            previous_edit.filename == next_edit.filename
423                && previous_edit
424                    .target_line_number
425                    .abs_diff(next_edit.target_line_number)
426                    > SAME_FILE_NEAR_LINE_THRESHOLD
427        }
428        SplitPointKind::CrossFile => previous_edit.filename != next_edit.filename,
429    }
430}
431
432fn sample_split_commit_of_kind(
433    patch: &Patch,
434    kind: SplitPointKind,
435    rng: &mut dyn rand::RngCore,
436) -> Result<GeneratedSplitCommit> {
437    let edit_locations = edit_locations(patch);
438    let num_edits = edit_locations.len();
439
440    let mut candidate_splits: Vec<usize> = (1..num_edits)
441        .filter(|&split| {
442            !edit_locations
443                .get(split)
444                .is_some_and(|next_edit| is_service_file(&next_edit.filename))
445                && split_can_match_kind(&edit_locations, split, kind)
446        })
447        .collect();
448    candidate_splits.shuffle(rng);
449
450    for split in candidate_splits {
451        for _ in 0..MAX_SPLIT_POINT_SAMPLING_ATTEMPTS {
452            let Ok(generated_split_commit) = generate_split_commit_at_split(patch, split, rng)
453            else {
454                continue;
455            };
456
457            if classify_generated_split_commit(&generated_split_commit) == Some(kind) {
458                return Ok(generated_split_commit);
459            }
460        }
461    }
462
463    Err(NoMatchingSplitPointError { kind }.into())
464}
465
466/// Entry point for the `ep split-commit` subcommand.
467///
468/// This runs synchronously and outputs JSON Lines (one output per input line).
469pub fn run_split_commit(
470    args: &SplitCommitArgs,
471    inputs: &[PathBuf],
472    output_path: Option<&PathBuf>,
473    failed: FailedHandling,
474) -> Result<()> {
475    use std::collections::HashSet;
476    use std::io::BufRead;
477
478    let stdin_path = PathBuf::from("-");
479    let inputs = if inputs.is_empty() {
480        std::slice::from_ref(&stdin_path)
481    } else {
482        inputs
483    };
484
485    let split_point = args
486        .split_point
487        .as_deref()
488        .map(parse_split_point)
489        .transpose()?;
490    let mut output_lines = Vec::new();
491    let mut processed_commits = 0usize;
492
493    for input_path in inputs {
494        let input: Box<dyn BufRead> = if input_path.as_os_str() == "-" {
495            Box::new(io::BufReader::new(io::stdin()))
496        } else {
497            let file = fs::File::open(input_path)
498                .with_context(|| format!("failed to open input file {}", input_path.display()))?;
499            Box::new(io::BufReader::new(file))
500        };
501
502        for (line_num, line_result) in input.lines().enumerate() {
503            let line =
504                line_result.with_context(|| format!("failed to read line {}", line_num + 1))?;
505
506            if line.trim().is_empty() {
507                continue;
508            }
509
510            let annotated: AnnotatedCommit = serde_json::from_str(&line)
511                .with_context(|| format!("failed to parse JSON at line {}", line_num + 1))?;
512
513            // Generate multiple samples if num_samples is set
514            if let Some(num_samples) = args.num_samples {
515                let mut seen_samples: HashSet<String> = HashSet::new();
516                let base_seed = args.seed.unwrap_or_else(|| rand::random());
517
518                for sample_idx in 0..num_samples {
519                    let sample_seed = base_seed.wrapping_add(sample_idx as u64);
520
521                    let case = match generate_evaluation_example_from_ordered_commit(
522                        &annotated.reordered_commit,
523                        &annotated.repo_url,
524                        &annotated.commit_sha,
525                        split_point.clone(),
526                        Some(sample_seed),
527                        Some(sample_idx),
528                    ) {
529                        Ok(case) => case,
530                        Err(e) => {
531                            let err_msg = format!(
532                                "failed to generate evaluation example for commit {} at line {} (sample {}): {}",
533                                annotated.commit_sha,
534                                line_num + 1,
535                                sample_idx,
536                                e
537                            );
538                            if e.is::<NoMatchingSplitPointError>() {
539                                eprintln!("skipping: {}", err_msg);
540                                continue;
541                            }
542                            match failed {
543                                FailedHandling::Skip | FailedHandling::SkipNoFiles => {
544                                    eprintln!("{}", err_msg);
545                                    continue;
546                                }
547                                FailedHandling::Keep => {
548                                    anyhow::bail!(err_msg);
549                                }
550                            }
551                        }
552                    };
553
554                    let json = if args.pretty {
555                        serde_json::to_string_pretty(&case)
556                    } else {
557                        serde_json::to_string(&case)
558                    }
559                    .context("failed to serialize evaluation case as JSON")?;
560
561                    // Only add unique samples (different split points may produce same result)
562                    if seen_samples.insert(json.clone()) {
563                        output_lines.push(json);
564                    }
565                }
566            } else {
567                let case = match generate_evaluation_example_from_ordered_commit(
568                    &annotated.reordered_commit,
569                    &annotated.repo_url,
570                    &annotated.commit_sha,
571                    split_point.clone(),
572                    args.seed,
573                    None,
574                ) {
575                    Ok(case) => case,
576                    Err(e) => {
577                        let err_msg = format!(
578                            "failed to generate evaluation example for commit {} at line {}: {}",
579                            annotated.commit_sha,
580                            line_num + 1,
581                            e
582                        );
583                        if e.is::<NoMatchingSplitPointError>() {
584                            eprintln!("skipping: {}", err_msg);
585                            continue;
586                        }
587                        match failed {
588                            FailedHandling::Skip | FailedHandling::SkipNoFiles => {
589                                eprintln!("{}", err_msg);
590                                continue;
591                            }
592                            FailedHandling::Keep => {
593                                anyhow::bail!(err_msg);
594                            }
595                        }
596                    }
597                };
598
599                let json = if args.pretty {
600                    serde_json::to_string_pretty(&case)
601                } else {
602                    serde_json::to_string(&case)
603                }
604                .context("failed to serialize evaluation case as JSON")?;
605
606                output_lines.push(json);
607            }
608
609            processed_commits += 1;
610            eprint!(
611                "\rsplit-commit: processed {} commits, generated {} examples",
612                processed_commits,
613                output_lines.len()
614            );
615            io::stderr()
616                .flush()
617                .context("failed to flush progress to stderr")?;
618        }
619    }
620
621    if processed_commits > 0 {
622        eprintln!();
623    }
624
625    let output_content = output_lines.join("\n") + if output_lines.is_empty() { "" } else { "\n" };
626
627    if let Some(path) = output_path {
628        fs::write(path, &output_content)
629            .with_context(|| format!("failed to write output to {}", path.display()))?;
630    } else {
631        io::stdout()
632            .write_all(output_content.as_bytes())
633            .context("failed to write to stdout")?;
634    }
635
636    Ok(())
637}
638
639/// Main function to generate an evaluation example from an ordered commit.
640///
641/// # Arguments
642/// * `commit` - Chronologically ordered unified diff of the commit
643/// * `repository_url` - URL of the repository
644/// * `commit_hash` - Hash of the commit
645/// * `split_point` - Point at which the commit will be split (None for random)
646/// * `seed` - Optional seed for randomness
647/// * `sample_num` - Optional sample number for generating unique names
648pub fn generate_evaluation_example_from_ordered_commit(
649    commit: &str,
650    repository_url: &str,
651    commit_hash: &str,
652    split_point: Option<SplitPoint>,
653    seed: Option<u64>,
654    sample_num: Option<usize>,
655) -> Result<ExampleSpec> {
656    anyhow::ensure!(
657        !has_submodule_gitlink_hunk(commit),
658        "commit contains submodule/gitlink hunk"
659    );
660
661    let mut rng: Box<dyn rand::RngCore> = match seed {
662        Some(seed) => Box::new(rand::rngs::StdRng::seed_from_u64(seed)),
663        None => Box::new(rand::rngs::ThreadRng::default()),
664    };
665
666    // Parse and normalize the commit
667    let mut patch = Patch::parse_unified_diff(commit);
668
669    // Filter header to only keep lines starting with "//"
670    let header_lines: Vec<&str> = patch
671        .header
672        .lines()
673        .filter(|line| line.starts_with("//"))
674        .collect();
675    patch.header = if header_lines.is_empty() {
676        String::new()
677    } else {
678        header_lines.join("\n") + "\n"
679    };
680
681    // Compute the split point
682    let stats = patch.stats();
683    let num_edits = stats.added + stats.removed;
684
685    anyhow::ensure!(num_edits != 0, "no edits found in commit");
686
687    let generated_split_commit = match split_point {
688        None => {
689            let split = sample_split_point(&patch, rng.as_mut());
690            generate_split_commit_at_split(&patch, split, rng.as_mut())?
691        }
692        Some(SplitPoint::Fraction(fraction)) => {
693            let split = resolve_split_point_value(SplitPointValue::Fraction(fraction), num_edits);
694            generate_split_commit_at_split(&patch, split, rng.as_mut())?
695        }
696        Some(SplitPoint::Index(index)) => {
697            let split = resolve_split_point_value(SplitPointValue::Index(index), num_edits);
698            generate_split_commit_at_split(&patch, split, rng.as_mut())?
699        }
700        Some(SplitPoint::Kind(kind)) => sample_split_commit_of_kind(&patch, kind, rng.as_mut())?,
701        Some(SplitPoint::KindWithSplit { kind, split_point }) => {
702            let split = resolve_split_point_value(split_point, num_edits);
703            let generated_split_commit =
704                generate_split_commit_at_split(&patch, split, rng.as_mut())?;
705            let actual_kind = classify_generated_split_commit(&generated_split_commit);
706            anyhow::ensure!(
707                actual_kind == Some(kind),
708                "split point {split} classified as {}, expected {kind}",
709                actual_kind
710                    .map(|kind| kind.to_string())
711                    .unwrap_or_else(|| "empty-target".to_string())
712            );
713            generated_split_commit
714        }
715    };
716
717    let split = generated_split_commit.split;
718    let cursor = generated_split_commit.cursor;
719    let mut split_commit = generated_split_commit.split_commit;
720
721    // Get cursor excerpt
722    let cursor_excerpt = get_cursor_excerpt(
723        &cursor,
724        &split_commit.source_patch,
725        &split_commit.target_patch,
726    )
727    .context("failed to generate cursor excerpt")?;
728
729    // Where the source patch is empty, there's not enough info to make a
730    // meaningful prediction
731    if split == 0 {
732        split_commit.target_patch = String::new();
733    }
734
735    let repo_name = repository_url
736        .trim_end_matches('/')
737        .rsplit('/')
738        .next()
739        .unwrap_or("unknown");
740    let short_sha = &commit_hash[..commit_hash.len().min(8)];
741    let name = match sample_num {
742        Some(n) => format!("{}-{}-{}", repo_name, short_sha, n),
743        None => format!("{}-{}", repo_name, short_sha),
744    };
745
746    Ok(ExampleSpec {
747        name,
748        repository_url: repository_url.to_string(),
749        revision: format!("{}~1", commit_hash),
750        edit_history: split_commit.source_patch.clone(),
751        cursor_path: Path::new(&cursor.file).into(),
752        cursor_position: cursor_excerpt,
753        expected_patches: vec![split_commit.target_patch],
754        tags: vec![],
755        reasoning: None,
756        uncommitted_diff: String::new(),
757        recently_opened_files: Vec::new(),
758        recently_viewed_files: Vec::new(),
759        uncommitted_diff_contains_edit_history: false,
760        rejected_patch: None,
761
762        telemetry: None,
763        human_feedback: Vec::new(),
764        rating: None,
765    })
766}
767
768/// Split an ordered commit into source and target commits.
769///
770/// # Arguments
771/// * `commit` - Ordered commit string
772/// * `split_pos` - Position to split the commit (number of edited lines)
773///
774/// # Returns
775/// A tuple of (source_diff, target_diff)
776pub fn split_ordered_patch(patch: &Patch, split_pos: usize) -> (String, String) {
777    let source_edits: BTreeSet<usize> = (0..split_pos).collect();
778    let (source, mut target) = extract_edits(patch, &source_edits);
779    if !target.hunks.is_empty() {
780        if let Some(header) = header_for_edit(patch, split_pos) {
781            target.header = header;
782        }
783    }
784
785    let mut source_str = source.to_string();
786    let target_str = target.to_string();
787
788    // Strip last group header from the source (lines starting with "//" at the end)
789    let source_lines: Vec<&str> = source_str.lines().collect();
790    let mut end_idx = source_lines.len();
791    for i in (0..source_lines.len()).rev() {
792        if source_lines[i].starts_with("//") {
793            end_idx = i;
794        } else {
795            break;
796        }
797    }
798    if end_idx < source_lines.len() {
799        source_str = source_lines[..end_idx].join("\n");
800        if !source_str.is_empty() {
801            source_str.push('\n');
802        }
803    }
804
805    (source_str, target_str)
806}
807
808fn header_for_edit(patch: &Patch, edit_index: usize) -> Option<String> {
809    let edit_index = edit_index.try_into().ok()?;
810    let edit_location = locate_edited_line(patch, edit_index)?;
811    header_for_hunk(patch, edit_location.hunk_index)
812}
813
814fn header_for_hunk(patch: &Patch, hunk_index: usize) -> Option<String> {
815    for hunk in patch.hunks.get(..hunk_index)?.iter().rev() {
816        let mut header_lines = Vec::new();
817        for line in hunk.lines.iter().rev() {
818            let PatchLine::Garbage(line) = line else {
819                break;
820            };
821            if line.trim().is_empty() && header_lines.is_empty() {
822                continue;
823            }
824            if !line.starts_with("//") {
825                break;
826            }
827            header_lines.push(line.as_str());
828        }
829        if !header_lines.is_empty() {
830            return Some(render_reversed_header_lines(header_lines));
831        }
832    }
833
834    let header_lines = patch
835        .header
836        .lines()
837        .rev()
838        .skip_while(|line| line.trim().is_empty())
839        .take_while(|line| line.starts_with("//"))
840        .collect::<Vec<_>>();
841    (!header_lines.is_empty()).then(|| render_reversed_header_lines(header_lines))
842}
843
844fn render_reversed_header_lines(mut lines: Vec<&str>) -> String {
845    lines.reverse();
846    lines.join("\n") + "\n"
847}
848
849/// Calculate the weight for a split byte offset in `text`.
850///
851/// Higher weights indicate more natural pause points (e.g., after punctuation,
852/// at identifier boundaries). Lower weights indicate less natural points
853/// (e.g., mid-identifier).
854fn position_weight(text: &str, byte_offset: usize) -> u32 {
855    if byte_offset == 0 || byte_offset > text.len() || !text.is_char_boundary(byte_offset) {
856        return 1;
857    }
858
859    let Some(prev_char) = text[..byte_offset].chars().next_back() else {
860        return 1;
861    };
862    let next_char = text[byte_offset..].chars().next();
863
864    // High weight: natural pause points (end of statement/argument, opening brackets)
865    if matches!(prev_char, ',' | ';' | ':' | '(' | '[' | '{') {
866        return 10;
867    }
868
869    // High weight: closing brackets (finished a group)
870    if matches!(prev_char, ')' | ']' | '}') {
871        return 8;
872    }
873
874    // Medium weight: operators and method chains
875    if matches!(
876        prev_char,
877        '.' | '+' | '-' | '*' | '/' | '=' | '<' | '>' | '&' | '|' | '!'
878    ) {
879        return 5;
880    }
881
882    // Check if we're at the end of an identifier (word char followed by non-word char)
883    let is_prev_word_char = prev_char.is_alphanumeric() || prev_char == '_';
884    let is_next_word_char = next_char.is_some_and(|ch| ch.is_alphanumeric() || ch == '_');
885
886    if is_prev_word_char && !is_next_word_char {
887        // End of identifier - high weight
888        return 8;
889    }
890
891    // Whitespace is a natural pause
892    if prev_char.is_whitespace() {
893        return 6;
894    }
895
896    // Mid-identifier: low weight (rare autocomplete scenarios)
897    if is_prev_word_char && is_next_word_char {
898        return 1;
899    }
900
901    // Default medium-low weight
902    3
903}
904
905/// Select a weighted random index from a list of weights.
906///
907/// Returns an index based on the weights, using the provided seed for
908/// deterministic selection.
909#[cfg(test)]
910fn weighted_select(weights: &[u32], seed: u64) -> usize {
911    if weights.is_empty() {
912        return 0;
913    }
914
915    let total_weight: u64 = weights.iter().map(|&w| w as u64).sum();
916    if total_weight == 0 {
917        // Fallback to uniform selection if all weights are zero
918        return seed as usize % weights.len();
919    }
920
921    // Use seed to select a value in [0, total_weight)
922    let target = seed % total_weight;
923    let mut cumulative: u64 = 0;
924
925    for (idx, &weight) in weights.iter().enumerate() {
926        cumulative += weight as u64;
927        if target < cumulative {
928            return idx;
929        }
930    }
931
932    // Fallback to last index
933    weights.len() - 1
934}
935
936#[derive(Clone, Copy)]
937struct CandidateSplit {
938    edit_byte_offset: usize,
939    weight: u32,
940}
941
942fn push_typed_text_candidates(
943    candidates: &mut Vec<CandidateSplit>,
944    edit_start_byte_offset: usize,
945    final_line: &str,
946    final_line_start_byte_offset: usize,
947    typed_text: &str,
948) {
949    for (byte_offset, character) in typed_text.char_indices() {
950        let next_byte_offset = byte_offset + character.len_utf8();
951        let final_line_candidate_byte_offset = final_line_start_byte_offset + next_byte_offset;
952        if final_line[..final_line_candidate_byte_offset]
953            .trim()
954            .is_empty()
955        {
956            continue;
957        }
958        candidates.push(CandidateSplit {
959            edit_byte_offset: edit_start_byte_offset + next_byte_offset,
960            weight: position_weight(final_line, final_line_candidate_byte_offset),
961        });
962    }
963}
964
965fn push_deleted_text_candidates(
966    candidates: &mut Vec<CandidateSplit>,
967    edit_start_byte_offset: usize,
968    deleted_text: &str,
969) {
970    for (byte_offset, character) in deleted_text.char_indices() {
971        candidates.push(CandidateSplit {
972            edit_byte_offset: edit_start_byte_offset + byte_offset + character.len_utf8(),
973            weight: 2,
974        });
975    }
976}
977
978fn weighted_select_candidate(candidates: &[CandidateSplit], seed: u64) -> Option<CandidateSplit> {
979    if candidates.is_empty() {
980        return None;
981    }
982
983    let total_weight: u64 = candidates
984        .iter()
985        .map(|candidate| candidate.weight as u64)
986        .sum();
987    if total_weight == 0 {
988        return Some(candidates[seed as usize % candidates.len()]);
989    }
990
991    let target = seed % total_weight;
992    let mut cumulative: u64 = 0;
993
994    for candidate in candidates {
995        cumulative += candidate.weight as u64;
996        if target < cumulative {
997            return Some(*candidate);
998        }
999    }
1000
1001    candidates.last().copied()
1002}
1003
1004/// Calculate similarity ratio between two strings (0-100).
1005fn fuzzy_ratio(s1: &str, s2: &str) -> u32 {
1006    if s1.is_empty() && s2.is_empty() {
1007        return 100;
1008    }
1009    if s1.is_empty() || s2.is_empty() {
1010        return 0;
1011    }
1012
1013    let diff = TextDiff::from_chars(s1, s2);
1014    let matching: usize = diff
1015        .ops()
1016        .iter()
1017        .filter_map(|op| {
1018            if matches!(op.tag(), DiffTag::Equal) {
1019                Some(op.new_range().len())
1020            } else {
1021                None
1022            }
1023        })
1024        .sum();
1025
1026    let total = s1.len() + s2.len();
1027    ((2 * matching * 100) / total) as u32
1028}
1029
1030/// Imitate human edits by introducing partial line edits.
1031///
1032/// This function simulates how a human might incrementally type code,
1033/// rather than making complete line replacements.
1034pub fn imitate_human_edits(
1035    source_patch: &str,
1036    target_patch: &str,
1037    seed: u64,
1038) -> (String, String, Option<CursorPosition>) {
1039    let no_change = (source_patch.to_string(), target_patch.to_string(), None);
1040
1041    let src_patch = Patch::parse_unified_diff(source_patch);
1042    let tgt_patch = Patch::parse_unified_diff(target_patch);
1043
1044    if tgt_patch.hunks.is_empty() {
1045        return no_change;
1046    }
1047
1048    // Try to locate the first edit in target
1049    let tgt_edit_loc = match locate_edited_line(&tgt_patch, 0) {
1050        Some(loc) => loc,
1051        None => return no_change,
1052    };
1053
1054    let tgt_is_addition = matches!(tgt_edit_loc.patch_line, PatchLine::Addition(_));
1055    if !tgt_is_addition {
1056        return no_change;
1057    }
1058
1059    let tgt_line = match &tgt_edit_loc.patch_line {
1060        PatchLine::Addition(s) => s.clone(),
1061        _ => return no_change,
1062    };
1063
1064    let source_edit_locations = edit_locations(&src_patch);
1065    let src_edit_loc = source_edit_locations.last().cloned();
1066
1067    let src_has_edit_at_target_line = source_edit_locations.iter().any(|loc| {
1068        loc.filename == tgt_edit_loc.filename
1069            && loc.target_line_number == tgt_edit_loc.target_line_number
1070    });
1071
1072    // Check if this is a replacement (deletion followed by insertion on the same line)
1073    // or a pure insertion (no corresponding deletion in source)
1074    let is_replacement = src_edit_loc.as_ref().map_or(false, |loc| {
1075        matches!(loc.patch_line, PatchLine::Deletion(_))
1076            && loc.filename == tgt_edit_loc.filename
1077            && loc.target_line_number == tgt_edit_loc.target_line_number
1078    });
1079
1080    // If source has an edit at the same line but it's not a replacement (i.e., it's an addition),
1081    // we shouldn't process this as a pure insertion either
1082    if !is_replacement && src_has_edit_at_target_line {
1083        return no_change;
1084    }
1085
1086    let src_line = if is_replacement {
1087        match &src_edit_loc.as_ref().unwrap().patch_line {
1088            PatchLine::Deletion(s) => s.clone(),
1089            _ => return no_change,
1090        }
1091    } else {
1092        // Pure insertion: source line is empty
1093        String::new()
1094    };
1095
1096    // Don't process if source and target are the same
1097    if src_line == tgt_line {
1098        return no_change;
1099    }
1100
1101    // Tokenize both lines
1102    let src_tokens = tokenize(&src_line);
1103    let tgt_tokens = tokenize(&tgt_line);
1104
1105    // Use similar to get diff operations
1106    let diff = TextDiff::from_slices(&src_tokens, &tgt_tokens);
1107
1108    let mut candidate_splits = Vec::new();
1109    let mut edit_byte_offset = 0usize;
1110    let mut final_line_byte_offset = 0usize;
1111
1112    for op in diff.ops() {
1113        match op.tag() {
1114            DiffTag::Equal => {
1115                let equal_text: String = op.old_range().map(|i| src_tokens[i]).collect();
1116                final_line_byte_offset += equal_text.len();
1117            }
1118            DiffTag::Replace => {
1119                let inserted_text: String = op.new_range().map(|i| tgt_tokens[i]).collect();
1120                let deleted_text: String = op.old_range().map(|i| src_tokens[i]).collect();
1121                push_typed_text_candidates(
1122                    &mut candidate_splits,
1123                    edit_byte_offset,
1124                    &tgt_line,
1125                    final_line_byte_offset,
1126                    &inserted_text,
1127                );
1128                push_deleted_text_candidates(
1129                    &mut candidate_splits,
1130                    edit_byte_offset + inserted_text.len(),
1131                    &deleted_text,
1132                );
1133                edit_byte_offset += inserted_text.len() + deleted_text.len();
1134                final_line_byte_offset += inserted_text.len();
1135            }
1136            DiffTag::Insert => {
1137                let inserted_text: String = op.new_range().map(|i| tgt_tokens[i]).collect();
1138                push_typed_text_candidates(
1139                    &mut candidate_splits,
1140                    edit_byte_offset,
1141                    &tgt_line,
1142                    final_line_byte_offset,
1143                    &inserted_text,
1144                );
1145                edit_byte_offset += inserted_text.len();
1146                final_line_byte_offset += inserted_text.len();
1147            }
1148            DiffTag::Delete => {
1149                let deleted_text: String = op.old_range().map(|i| src_tokens[i]).collect();
1150                push_deleted_text_candidates(
1151                    &mut candidate_splits,
1152                    edit_byte_offset,
1153                    &deleted_text,
1154                );
1155                edit_byte_offset += deleted_text.len();
1156            }
1157        }
1158    }
1159
1160    let Some(selected_split) = weighted_select_candidate(&candidate_splits, seed) else {
1161        return no_change;
1162    };
1163    let split_byte_offset = selected_split.edit_byte_offset;
1164
1165    let mut edit_index = 0usize;
1166    let mut new_src = String::new();
1167    let mut split_found = false;
1168    let mut last_old_end = 0usize;
1169
1170    for op in diff.ops() {
1171        match op.tag() {
1172            DiffTag::Equal => {
1173                for i in op.old_range() {
1174                    new_src.push_str(src_tokens[i]);
1175                }
1176                last_old_end = op.old_range().end;
1177            }
1178            DiffTag::Replace => {
1179                // Handle replace as delete + insert
1180                let del: String = op.old_range().map(|i| src_tokens[i]).collect();
1181                let ins: String = op.new_range().map(|i| tgt_tokens[i]).collect();
1182                let repl_len = del.len() + ins.len();
1183                if edit_index + repl_len >= split_byte_offset {
1184                    // Split within this replace operation
1185                    let offset = split_byte_offset - edit_index;
1186                    if offset < ins.len() {
1187                        let safe_offset = floor_char_boundary(&ins, offset);
1188                        new_src.push_str(&ins[..safe_offset]);
1189                    } else {
1190                        new_src.push_str(&ins);
1191                        let del_offset = offset - ins.len();
1192                        let safe_del_offset = floor_char_boundary(&del, del_offset.min(del.len()));
1193                        new_src.push_str(&del[..safe_del_offset]);
1194                    }
1195                    split_found = true;
1196                    last_old_end = op.old_range().end;
1197                    break;
1198                } else {
1199                    edit_index += repl_len;
1200                    new_src.push_str(&ins);
1201                    last_old_end = op.old_range().end;
1202                }
1203            }
1204            DiffTag::Insert => {
1205                let repl: String = op.new_range().map(|i| tgt_tokens[i]).collect();
1206                if edit_index + repl.len() >= split_byte_offset {
1207                    let offset = split_byte_offset - edit_index;
1208                    let safe_offset = floor_char_boundary(&repl, offset);
1209                    new_src.push_str(&repl[..safe_offset]);
1210                    split_found = true;
1211                    break;
1212                } else {
1213                    edit_index += repl.len();
1214                    new_src.push_str(&repl);
1215                }
1216            }
1217            DiffTag::Delete => {
1218                let repl: String = op.old_range().map(|i| src_tokens[i]).collect();
1219                if edit_index + repl.len() >= split_byte_offset {
1220                    let offset = split_byte_offset - edit_index;
1221                    let safe_offset = floor_char_boundary(&repl, offset);
1222                    new_src.push_str(&repl[..safe_offset]);
1223                    split_found = true;
1224                    last_old_end = op.old_range().start + safe_offset.min(op.old_range().len());
1225                    break;
1226                } else {
1227                    edit_index += repl.len();
1228                    new_src.push_str(&repl);
1229                    last_old_end = op.old_range().end;
1230                }
1231            }
1232        }
1233    }
1234
1235    if !split_found {
1236        return no_change;
1237    }
1238
1239    // Calculate cursor position
1240    let line = if is_replacement {
1241        src_edit_loc.as_ref().unwrap().source_line_number
1242    } else {
1243        tgt_edit_loc.target_line_number
1244    };
1245    let column = new_src.len() + 1;
1246
1247    // Add remainder of source if similar enough to target remainder
1248    let remainder_src: String = (last_old_end..src_tokens.len())
1249        .map(|i| src_tokens[i])
1250        .collect();
1251    let remainder_tgt: String = (last_old_end..tgt_tokens.len())
1252        .filter_map(|i| tgt_tokens.get(i).copied())
1253        .collect();
1254
1255    let ratio = fuzzy_ratio(&remainder_src, &remainder_tgt);
1256    if ratio > 35 {
1257        new_src.push_str(&remainder_src);
1258    }
1259
1260    if new_src.trim().is_empty() {
1261        return no_change;
1262    }
1263
1264    if new_src == src_line {
1265        return no_change;
1266    }
1267
1268    let cursor = CursorPosition {
1269        file: tgt_edit_loc.filename.clone(),
1270        line,
1271        column: column.min(new_src.len()),
1272        line_length: new_src.len(),
1273    };
1274
1275    // Build new source patch with the intermediate line
1276    let mut new_src_patch = src_patch;
1277    if is_replacement {
1278        // For replacements, insert after the deletion line
1279        let src_loc = src_edit_loc.as_ref().unwrap();
1280        if let Some(hunk) = new_src_patch.hunks.get_mut(src_loc.hunk_index) {
1281            hunk.lines.insert(
1282                src_loc.line_index_within_hunk + 1,
1283                PatchLine::Addition(new_src.clone()),
1284            );
1285            hunk.new_count += 1;
1286        }
1287    } else {
1288        // For pure insertions, insert after the last edit in source patch
1289        // This imitates human typing - the intermediate content is what the user is currently typing
1290        let last_src_edit = locate_edited_line(&new_src_patch, -1);
1291
1292        if let Some(src_loc) = last_src_edit {
1293            // Insert after the last edit in source
1294            if let Some(hunk) = new_src_patch.hunks.get_mut(src_loc.hunk_index) {
1295                hunk.lines.insert(
1296                    src_loc.line_index_within_hunk + 1,
1297                    PatchLine::Addition(new_src.clone()),
1298                );
1299                hunk.new_count += 1;
1300            }
1301        } else {
1302            // Source patch is empty or has incompatible hunk structure, create a new hunk based on target
1303            if let Some(tgt_hunk) = tgt_patch.hunks.get(tgt_edit_loc.hunk_index) {
1304                let mut new_hunk = tgt_hunk.clone();
1305                // Replace the full addition with the partial one
1306                new_hunk.lines.clear();
1307                for (i, line) in tgt_hunk.lines.iter().enumerate() {
1308                    if i == tgt_edit_loc.line_index_within_hunk {
1309                        new_hunk.lines.push(PatchLine::Addition(new_src.clone()));
1310                    } else {
1311                        match line {
1312                            PatchLine::Addition(_) => {
1313                                // Skip other additions from target
1314                            }
1315                            _ => new_hunk.lines.push(line.clone()),
1316                        }
1317                    }
1318                }
1319                new_hunk.new_count = new_hunk.old_count + 1;
1320                new_src_patch.hunks.push(new_hunk);
1321                // Copy header from target if source doesn't have one
1322                if new_src_patch.header.is_empty() {
1323                    new_src_patch.header = tgt_patch.header.clone();
1324                }
1325            }
1326        }
1327    }
1328
1329    // Build new target patch with the intermediate line as deletion
1330    let mut new_tgt_patch = tgt_patch;
1331    if let Some(hunk) = new_tgt_patch.hunks.get_mut(tgt_edit_loc.hunk_index) {
1332        hunk.lines.insert(
1333            tgt_edit_loc.line_index_within_hunk,
1334            PatchLine::Deletion(new_src),
1335        );
1336        hunk.old_count += 1;
1337    }
1338
1339    (
1340        new_src_patch.to_string(),
1341        new_tgt_patch.to_string(),
1342        Some(cursor),
1343    )
1344}
1345
1346/// Locate the end of the last edit in a patch.
1347fn locate_end_of_last_edit(patch: &Patch) -> Option<CursorPosition> {
1348    let loc = locate_edited_line(patch, -1)?;
1349
1350    let (line, column, line_length) = match &loc.patch_line {
1351        PatchLine::Addition(content) => (loc.target_line_number, content.len(), content.len()),
1352        PatchLine::Deletion(_) => (loc.target_line_number, 1, 1),
1353        _ => return None,
1354    };
1355
1356    Some(CursorPosition {
1357        file: loc.filename,
1358        line,
1359        column,
1360        line_length,
1361    })
1362}
1363
1364/// Locate the beginning of the first edit in a patch.
1365fn locate_beginning_of_first_edit(patch: &Patch) -> Option<CursorPosition> {
1366    let loc = locate_edited_line(patch, 0)?;
1367
1368    let hunk = patch.hunks.get(loc.hunk_index)?;
1369    let line_length = if loc.line_index_within_hunk > 0 {
1370        if let Some(prev_line) = hunk.lines.get(loc.line_index_within_hunk - 1) {
1371            let content = match prev_line {
1372                PatchLine::Context(s) | PatchLine::Addition(s) | PatchLine::Deletion(s) => s,
1373                _ => return None,
1374            };
1375            content.len().max(1) - 1
1376        } else {
1377            0
1378        }
1379    } else {
1380        0
1381    };
1382
1383    let line = loc.target_line_number.saturating_sub(1).max(1);
1384    let column = line_length.saturating_sub(1);
1385
1386    Some(CursorPosition {
1387        file: loc.filename,
1388        line,
1389        column,
1390        line_length,
1391    })
1392}
1393
1394/// Sample cursor position according to the following rules:
1395/// 1. 80% chance of cursor being at the end of the source patch
1396/// 2. 20% chance of cursor being at the beginning of the target patch
1397/// 3. 20% chance of adding a jitter offset
1398pub fn sample_cursor_position(
1399    split_commit: &SplitCommit,
1400    rng: &mut dyn rand::RngCore,
1401) -> Option<CursorPosition> {
1402    // End of history
1403    let src_patch = Patch::parse_unified_diff(&split_commit.source_patch);
1404    let src_cursor = locate_end_of_last_edit(&src_patch);
1405
1406    // Beginning of target
1407    let tgt_patch = Patch::parse_unified_diff(&split_commit.target_patch);
1408    let tgt_cursor = locate_beginning_of_first_edit(&tgt_patch);
1409
1410    // Randomly pick a cursor position
1411    let prefer_source = rng.random_bool(0.8);
1412    let mut cursor = if prefer_source {
1413        src_cursor.or(tgt_cursor)
1414    } else {
1415        tgt_cursor.or(src_cursor)
1416    };
1417
1418    // Possible add jitter
1419    let should_jitter = rng.random_bool(0.2);
1420    if should_jitter {
1421        if let Some(cursor) = cursor.as_mut() {
1422            let col_offset = rng.random_range(1..=5);
1423            if rng.random_bool(0.5) {
1424                cursor.column = cursor
1425                    .column
1426                    .saturating_add(col_offset)
1427                    .min(cursor.line_length);
1428            } else {
1429                cursor.column = cursor.column.saturating_sub(col_offset);
1430            }
1431        }
1432    }
1433
1434    cursor
1435}
1436
1437/// Get cursor excerpt from the patches.
1438///
1439/// This extracts the lines around the cursor position with a cursor marker.
1440pub fn get_cursor_excerpt(
1441    cursor: &CursorPosition,
1442    source_patch: &str,
1443    target_patch: &str,
1444) -> Option<String> {
1445    let mut excerpt_lines: Vec<String> = Vec::new();
1446    let mut excerpt_first_line: usize = 0;
1447
1448    // Search in the last hunk of source patch
1449    let src = Patch::parse_unified_diff(source_patch);
1450    if let Some(loc) = locate_edited_line(&src, -1) {
1451        if loc.filename == cursor.file && loc.target_line_number == cursor.line {
1452            if let Some(hunk) = src.hunks.get(loc.hunk_index) {
1453                excerpt_first_line = hunk.new_start as usize;
1454                for line in &hunk.lines {
1455                    match line {
1456                        PatchLine::Addition(s) | PatchLine::Context(s) => {
1457                            excerpt_lines.push(s.clone());
1458                        }
1459                        _ => {}
1460                    }
1461                }
1462                // If hunk only has deletions (file deletion), include deletion lines
1463                if excerpt_lines.is_empty() {
1464                    excerpt_first_line = hunk.old_start as usize;
1465                    for line in &hunk.lines {
1466                        match line {
1467                            PatchLine::Deletion(s) => {
1468                                excerpt_lines.push(s.clone());
1469                            }
1470                            _ => {}
1471                        }
1472                    }
1473                }
1474            }
1475        }
1476    }
1477
1478    // Search in target patch if not found
1479    if excerpt_lines.is_empty() {
1480        let tgt = Patch::parse_unified_diff(target_patch);
1481        // Search all hunks for the cursor file, not just the first edit's hunk
1482        for hunk in &tgt.hunks {
1483            if hunk.filename == cursor.file {
1484                excerpt_first_line = hunk.new_start as usize;
1485                // First try to collect deletions and context (what exists before edits)
1486                for line in &hunk.lines {
1487                    match line {
1488                        PatchLine::Deletion(s) | PatchLine::Context(s) => {
1489                            excerpt_lines.push(s.clone());
1490                        }
1491                        _ => {}
1492                    }
1493                }
1494                // If hunk only has additions (no deletions/context), include all lines
1495                // This handles cases like adding to an empty file or section
1496                if excerpt_lines.is_empty() {
1497                    for line in &hunk.lines {
1498                        match line {
1499                            PatchLine::Addition(s)
1500                            | PatchLine::Deletion(s)
1501                            | PatchLine::Context(s) => {
1502                                excerpt_lines.push(s.clone());
1503                            }
1504                            _ => {}
1505                        }
1506                    }
1507                }
1508                if !excerpt_lines.is_empty() {
1509                    break;
1510                }
1511            }
1512        }
1513    }
1514
1515    // Also search source patch hunks if still not found (for fallback cursor case)
1516    if excerpt_lines.is_empty() {
1517        for hunk in &src.hunks {
1518            if hunk.filename == cursor.file {
1519                excerpt_first_line = hunk.new_start as usize;
1520                for line in &hunk.lines {
1521                    match line {
1522                        PatchLine::Addition(s) | PatchLine::Context(s) => {
1523                            excerpt_lines.push(s.clone());
1524                        }
1525                        _ => {}
1526                    }
1527                }
1528                // If hunk only has deletions, include deletion lines
1529                if excerpt_lines.is_empty() {
1530                    excerpt_first_line = hunk.old_start as usize;
1531                    for line in &hunk.lines {
1532                        match line {
1533                            PatchLine::Deletion(s) => {
1534                                excerpt_lines.push(s.clone());
1535                            }
1536                            _ => {}
1537                        }
1538                    }
1539                }
1540                if !excerpt_lines.is_empty() {
1541                    break;
1542                }
1543            }
1544        }
1545    }
1546
1547    if excerpt_lines.is_empty() {
1548        return None;
1549    }
1550
1551    // Add cursor marker
1552    for (i, line) in excerpt_lines.iter_mut().enumerate() {
1553        let line_num = excerpt_first_line + i;
1554        if line_num == cursor.line {
1555            let col = cursor.column.min(line.len());
1556            // Ensure we split at a valid UTF-8 character boundary
1557            let col = if line.is_char_boundary(col) {
1558                col
1559            } else {
1560                // Find the nearest valid character boundary
1561                (0..=col)
1562                    .rev()
1563                    .find(|&i| line.is_char_boundary(i))
1564                    .unwrap_or(0)
1565            };
1566            let (before, after) = line.split_at(col);
1567            *line = format!("{}<|user_cursor|>{}", before, after);
1568            break;
1569        }
1570    }
1571
1572    Some(excerpt_lines.join("\n"))
1573}
1574
1575#[cfg(test)]
1576mod tests {
1577    use std::path::Path;
1578
1579    use edit_prediction::example_spec::ExampleSpec;
1580
1581    use super::*;
1582
1583    #[test]
1584    fn test_tokenize() {
1585        let tokens = tokenize("hello world");
1586        assert_eq!(tokens, vec!["hello", " ", "world"]);
1587
1588        let tokens = tokenize("foo_bar123 + baz");
1589        assert_eq!(tokens, vec!["foo_bar123", " ", "+", " ", "baz"]);
1590
1591        let tokens = tokenize("print(\"hello\")");
1592        assert_eq!(tokens, vec!["print", "(", "\"", "hello", "\"", ")"]);
1593
1594        let tokens = tokenize("hello_world");
1595        assert_eq!(tokens, vec!["hello_world"]);
1596
1597        let tokens = tokenize("fn();");
1598        assert_eq!(tokens, vec!["fn", "(", ")", ";"]);
1599    }
1600
1601    #[test]
1602    fn test_fuzzy_ratio() {
1603        assert_eq!(fuzzy_ratio("hello", "hello"), 100);
1604        assert_eq!(fuzzy_ratio("", ""), 100);
1605        assert!(fuzzy_ratio("hello", "world") < 50);
1606        assert!(fuzzy_ratio("hello world", "hello worl") > 80);
1607    }
1608
1609    #[test]
1610    fn test_split_ordered_commit() {
1611        let commit = r#"// First change
1612--- a/test.rs
1613+++ b/test.rs
1614@@ -1,3 +1,4 @@
1615 fn main() {
1616+    println!("hello");
1617+    println!("world");
1618 }
1619"#;
1620        let patch = Patch::parse_unified_diff(commit);
1621        let stats = patch.stats();
1622        assert_eq!(stats.added, 2);
1623
1624        let (source, target) = split_ordered_patch(&patch, 1);
1625
1626        // Source should have 1 addition
1627        let src_patch = Patch::parse_unified_diff(&source);
1628        assert_eq!(src_patch.stats().added, 1);
1629
1630        // Target should have 1 addition
1631        let tgt_patch = Patch::parse_unified_diff(&target);
1632        assert_eq!(tgt_patch.stats().added, 1);
1633    }
1634
1635    #[test]
1636    fn test_split_ordered_commit_with_deletions() {
1637        let commit = r#"// Change
1638--- a/test.rs
1639+++ b/test.rs
1640@@ -1,3 +1,3 @@
1641 fn main() {
1642-    println!("old");
1643+    println!("new");
1644 }
1645"#;
1646        let patch = Patch::parse_unified_diff(commit);
1647        let stats = patch.stats();
1648        assert_eq!(stats.added, 1);
1649        assert_eq!(stats.removed, 1);
1650
1651        // Split at position 1 (after the deletion)
1652        let (source, target) = split_ordered_patch(&patch, 1);
1653
1654        let src_patch = Patch::parse_unified_diff(&source);
1655        let tgt_patch = Patch::parse_unified_diff(&target);
1656
1657        // Source should have the deletion
1658        assert_eq!(src_patch.stats().removed, 1);
1659        // Target should have the addition
1660        assert_eq!(tgt_patch.stats().added, 1);
1661    }
1662
1663    #[test]
1664    fn test_split_ordered_commit_target_header_continues_current_group() {
1665        let commit = r#"////////////////////////////////////////////////////////////////////////////////
1666// Update dependency version
1667////////////////////////////////////////////////////////////////////////////////
1668--- a/go.mod
1669+++ b/go.mod
1670@@ -1,3 +1,3 @@
1671 require (
1672-	gopkg.in/yaml.v3 v3.0.0 // indirect
1673+	gopkg.in/yaml.v3 v3.0.1 // indirect
1674 )
1675diff --git a/go.sum b/go.sum
1676index f71a068..b8cc3c2 100644
1677////////////////////////////////////////////////////////////////////////////////
1678// Update go.sum checksums
1679////////////////////////////////////////////////////////////////////////////////
1680--- a/go.sum
1681+++ b/go.sum
1682@@ -1,3 +1,5 @@
1683 gopkg.in/yaml.v3 v3.0.0 h1:old
1684 gopkg.in/yaml.v3 v3.0.0/go.mod h1:oldmod
1685+gopkg.in/yaml.v3 v3.0.1 h1:new
1686+gopkg.in/yaml.v3 v3.0.1/go.mod h1:newmod
1687diff --git a/lib/handler.go b/lib/handler.go
1688index 1827a70..d9b3ed1 100644
1689////////////////////////////////////////////////////////////////////////////////
1690// Fix error wrapping
1691////////////////////////////////////////////////////////////////////////////////
1692--- a/lib/handler.go
1693+++ b/lib/handler.go
1694@@ -1,3 +1,3 @@
1695-	return fmt.Errorf("failed: %s", err)
1696+	return fmt.Errorf("failed: %w", err)
1697"#;
1698
1699        let (_source, target) = split_ordered_patch(&Patch::parse_unified_diff(commit), 3);
1700
1701        assert!(
1702            target.starts_with(
1703                "////////////////////////////////////////////////////////////////////////////////\n// Update go.sum checksums\n////////////////////////////////////////////////////////////////////////////////\n"
1704            ),
1705            "target patch should continue with the active group header:\n{target}"
1706        );
1707        assert!(!target.starts_with(
1708            "////////////////////////////////////////////////////////////////////////////////\n// Update dependency version\n////////////////////////////////////////////////////////////////////////////////\n"
1709        ));
1710    }
1711
1712    #[test]
1713    fn test_generate_evaluation_example() {
1714        let commit = r#"commit abc123
1715Author: Test <test@example.com>
1716Date: Mon Jan 1 00:00:00 2024
1717
1718    Test commit
1719
1720////////////////////////////////////////////////////////////////////////////////
1721// Add greeting
1722////////////////////////////////////////////////////////////////////////////////
1723--- a/test.rs
1724+++ b/test.rs
1725@@ -1,3 +1,5 @@
1726 fn main() {
1727+    println!("hello");
1728+    println!("world");
1729 }
1730"#;
1731
1732        let result = generate_evaluation_example_from_ordered_commit(
1733            commit,
1734            "https://github.com/test/repo",
1735            "abc123",
1736            Some(SplitPoint::Fraction(0.5)),
1737            Some(42),
1738            None,
1739        );
1740
1741        assert!(result.is_ok());
1742        let case = result.unwrap();
1743        assert_eq!(case.repository_url, "https://github.com/test/repo");
1744        assert_eq!(case.revision, "abc123~1");
1745        assert!(!case.edit_history.is_empty());
1746    }
1747
1748    #[test]
1749    fn test_generate_evaluation_example_reproducible() {
1750        let commit = r#"////////////////////////////////////////////////////////////////////////////////
1751// Add greeting
1752////////////////////////////////////////////////////////////////////////////////
1753--- a/test.rs
1754+++ b/test.rs
1755@@ -1,3 +1,5 @@
1756 fn main() {
1757+    println!("hello");
1758+    println!("world");
1759 }
1760"#;
1761
1762        // Run twice with the same seed
1763        let result1 = generate_evaluation_example_from_ordered_commit(
1764            commit,
1765            "https://github.com/test/repo",
1766            "abc123",
1767            Some(SplitPoint::Fraction(0.5)),
1768            Some(12345),
1769            None,
1770        )
1771        .unwrap();
1772
1773        let result2 = generate_evaluation_example_from_ordered_commit(
1774            commit,
1775            "https://github.com/test/repo",
1776            "abc123",
1777            Some(SplitPoint::Fraction(0.5)),
1778            Some(12345),
1779            None,
1780        )
1781        .unwrap();
1782
1783        // Results should be identical
1784        assert_eq!(result1.edit_history, result2.edit_history);
1785        assert_eq!(result1.expected_patches, result2.expected_patches);
1786        assert_eq!(result1.cursor_position, result2.cursor_position);
1787    }
1788
1789    #[test]
1790    fn test_cursor_position_display() {
1791        let cursor = CursorPosition {
1792            file: "src/main.rs".to_string(),
1793            line: 42,
1794            column: 10,
1795            line_length: 80,
1796        };
1797        assert_eq!(cursor.to_string(), "src/main.rs:42:10");
1798    }
1799
1800    #[test]
1801    fn test_imitate_human_edits_no_change_when_no_replacement() {
1802        // Source and target patches that don't form a replacement pattern
1803        let source = r#"--- a/test.rs
1804+++ b/test.rs
1805@@ -1,3 +1,4 @@
1806 fn main() {
1807+    println!("hello");
1808 }
1809"#;
1810        let target = r#"--- a/test.rs
1811+++ b/test.rs
1812@@ -1,3 +1,4 @@
1813 fn main() {
1814+    println!("world");
1815 }
1816"#;
1817
1818        let (new_src, new_tgt, cursor) = imitate_human_edits(source, target, 42);
1819
1820        // Should return unchanged when not a replacement pattern
1821        assert_eq!(new_src, source);
1822        assert_eq!(new_tgt, target);
1823        assert!(cursor.is_none());
1824    }
1825
1826    #[test]
1827    fn test_parse_typed_split_points() {
1828        assert_eq!(
1829            parse_split_point("fim").unwrap(),
1830            SplitPoint::Kind(SplitPointKind::Fim)
1831        );
1832        assert_eq!(
1833            parse_split_point("same-file-near").unwrap(),
1834            SplitPoint::Kind(SplitPointKind::SameFileNear)
1835        );
1836        assert_eq!(
1837            parse_split_point("same-file-far:2").unwrap(),
1838            SplitPoint::KindWithSplit {
1839                kind: SplitPointKind::SameFileFar,
1840                split_point: SplitPointValue::Index(2),
1841            }
1842        );
1843        assert_eq!(
1844            parse_split_point("cross-file:0.5").unwrap(),
1845            SplitPoint::KindWithSplit {
1846                kind: SplitPointKind::CrossFile,
1847                split_point: SplitPointValue::Fraction(0.5),
1848            }
1849        );
1850        assert!(parse_split_point("local").is_err());
1851    }
1852
1853    fn assert_generated_split_kind(
1854        commit: &str,
1855        kind: SplitPointKind,
1856        seed: u64,
1857    ) -> GeneratedSplitCommit {
1858        let patch = Patch::parse_unified_diff(commit);
1859        let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
1860        let generated_split_commit = sample_split_commit_of_kind(&patch, kind, &mut rng).unwrap();
1861        assert_eq!(
1862            classify_generated_split_commit(&generated_split_commit),
1863            Some(kind)
1864        );
1865        generated_split_commit
1866    }
1867
1868    #[test]
1869    fn test_classify_generated_split_commit() {
1870        let target_patch = r#"--- a/src/main.rs
1871+++ b/src/main.rs
1872@@ -10,3 +10,3 @@
1873 fn main() {
1874-old();
1875+new();
1876 }
1877"#;
1878        let mut generated_split_commit = GeneratedSplitCommit {
1879            split: 1,
1880            split_commit: SplitCommit {
1881                source_patch: String::new(),
1882                target_patch: target_patch.to_string(),
1883            },
1884            cursor: CursorPosition {
1885                file: "src/main.rs".to_string(),
1886                line: 11,
1887                column: 5,
1888                line_length: 10,
1889            },
1890            cursor_from_human_edit: true,
1891        };
1892        assert_eq!(
1893            classify_generated_split_commit(&generated_split_commit),
1894            Some(SplitPointKind::Fim)
1895        );
1896
1897        generated_split_commit.cursor_from_human_edit = false;
1898        assert_eq!(
1899            classify_generated_split_commit(&generated_split_commit),
1900            Some(SplitPointKind::SameFileNear)
1901        );
1902
1903        generated_split_commit.cursor.line = 100;
1904        assert_eq!(
1905            classify_generated_split_commit(&generated_split_commit),
1906            Some(SplitPointKind::SameFileFar)
1907        );
1908
1909        generated_split_commit.cursor.file = "src/other.rs".to_string();
1910        assert_eq!(
1911            classify_generated_split_commit(&generated_split_commit),
1912            Some(SplitPointKind::CrossFile)
1913        );
1914    }
1915
1916    #[test]
1917    fn test_sample_fim_split_point() {
1918        let commit = r#"--- a/src/main.rs
1919+++ b/src/main.rs
1920@@ -1,3 +1,5 @@
1921 fn main() {
1922+    let first = 1;
1923+    let second = 2;
1924 }
1925"#;
1926
1927        assert_generated_split_kind(commit, SplitPointKind::Fim, 1);
1928    }
1929
1930    #[test]
1931    fn test_sample_same_file_near_split_point() {
1932        let commit = r#"--- a/src/main.rs
1933+++ b/src/main.rs
1934@@ -1,4 +1,5 @@
1935 fn main() {
1936+    let inserted = 0;
1937-    old();
1938+    new();
1939 }
1940"#;
1941
1942        assert_generated_split_kind(commit, SplitPointKind::SameFileNear, 1);
1943    }
1944
1945    #[test]
1946    fn test_sample_same_file_far_split_point() {
1947        let commit = r#"--- a/src/main.rs
1948+++ b/src/main.rs
1949@@ -1,2 +1,3 @@
1950 start
1951+source_edit();
1952 context
1953@@ -100,2 +101,2 @@
1954-far_old();
1955+far_new();
1956 end
1957"#;
1958
1959        assert_generated_split_kind(commit, SplitPointKind::SameFileFar, 1);
1960    }
1961
1962    #[test]
1963    fn test_sample_cross_file_split_point() {
1964        let commit = r#"--- a/src/main.rs
1965+++ b/src/main.rs
1966@@ -1,2 +1,3 @@
1967 fn main() {
1968+    source_edit();
1969 }
1970--- a/src/other.rs
1971+++ b/src/other.rs
1972@@ -1,3 +1,3 @@
1973 fn other() {
1974-    old();
1975+    new();
1976 }
1977"#;
1978
1979        assert_generated_split_kind(commit, SplitPointKind::CrossFile, 1);
1980    }
1981
1982    #[test]
1983    fn test_split_point_fraction() {
1984        let commit = r#"// Change
1985--- a/test.rs
1986+++ b/test.rs
1987@@ -1,5 +1,10 @@
1988 fn main() {
1989+    line1();
1990+    line2();
1991+    line3();
1992+    line4();
1993+    line5();
1994 }
1995"#;
1996
1997        // Split at 20% should give first edit in source
1998        let result = generate_evaluation_example_from_ordered_commit(
1999            commit,
2000            "",
2001            "hash",
2002            Some(SplitPoint::Fraction(0.2)),
2003            Some(1),
2004            None,
2005        );
2006
2007        assert!(result.is_ok());
2008        let case = result.unwrap();
2009
2010        // Source should have some edits
2011        let src_patch = Patch::parse_unified_diff(&case.edit_history);
2012        assert!(src_patch.stats().added > 0);
2013    }
2014
2015    #[test]
2016    fn test_split_point_index() {
2017        let commit = r#"// Change
2018--- a/test.rs
2019+++ b/test.rs
2020@@ -1,5 +1,10 @@
2021 fn main() {
2022+    line1();
2023+    line2();
2024+    line3();
2025+    line4();
2026+    line5();
2027 }
2028"#;
2029
2030        // Split at index 2 should give first 2 edits in source
2031        // With pure insertion handling, source gets 2 original + 1 partial = 3 additions
2032        let result = generate_evaluation_example_from_ordered_commit(
2033            commit,
2034            "",
2035            "hash",
2036            Some(SplitPoint::Index(2)),
2037            Some(1),
2038            None,
2039        );
2040
2041        assert!(result.is_ok());
2042        let case = result.unwrap();
2043
2044        let src_patch = Patch::parse_unified_diff(&case.edit_history);
2045        // Pure insertion adds a partial line, so we expect 3 (2 original + 1 partial)
2046        assert_eq!(src_patch.stats().added, 3);
2047    }
2048
2049    #[test]
2050    fn test_cursor_excerpt_contains_marker() {
2051        let commit = r#"////////////////////////////////////////////////////////////////////////////////
2052// Add code
2053////////////////////////////////////////////////////////////////////////////////
2054--- a/test.rs
2055+++ b/test.rs
2056@@ -1,3 +1,5 @@
2057 fn main() {
2058+    println!("hello");
2059+    println!("world");
2060 }
2061"#;
2062
2063        let result = generate_evaluation_example_from_ordered_commit(
2064            commit,
2065            "",
2066            "hash",
2067            Some(SplitPoint::Fraction(0.5)),
2068            Some(42),
2069            None,
2070        )
2071        .unwrap();
2072
2073        // Cursor excerpt should contain the cursor marker
2074        assert!(
2075            result.cursor_position.contains("<|user_cursor|>"),
2076            "Cursor excerpt should contain marker: {}",
2077            result.cursor_position
2078        );
2079    }
2080
2081    #[test]
2082    fn test_evaluation_case_json_serialization() {
2083        let case = ExampleSpec {
2084            name: "test-abc123".to_string(),
2085            repository_url: "https://github.com/test/repo".to_string(),
2086            revision: "abc123~1".to_string(),
2087            edit_history: "patch1".to_string(),
2088            cursor_path: Path::new("file.rs").into(),
2089            cursor_position: "some code<|user_cursor|>".to_string(),
2090            expected_patches: vec!["patch".to_string()],
2091            tags: vec![],
2092            reasoning: None,
2093            uncommitted_diff: String::new(),
2094            recently_opened_files: Vec::new(),
2095            recently_viewed_files: Vec::new(),
2096            uncommitted_diff_contains_edit_history: false,
2097            rejected_patch: None,
2098
2099            telemetry: None,
2100            human_feedback: Vec::new(),
2101            rating: None,
2102        };
2103
2104        let json = serde_json::to_string(&case).unwrap();
2105        let deserialized: ExampleSpec = serde_json::from_str(&json).unwrap();
2106
2107        assert_eq!(case.repository_url, deserialized.repository_url);
2108        assert_eq!(case.revision, deserialized.revision);
2109        assert_eq!(case.cursor_position, deserialized.cursor_position);
2110    }
2111
2112    #[test]
2113    fn test_empty_commit_returns_error() {
2114        let commit = "";
2115
2116        let result = generate_evaluation_example_from_ordered_commit(
2117            commit,
2118            "",
2119            "hash",
2120            Some(SplitPoint::Fraction(0.5)),
2121            Some(1),
2122            None,
2123        );
2124
2125        assert!(result.is_err());
2126    }
2127
2128    #[test]
2129    fn test_header_filtering() {
2130        let commit = r#"commit abc123
2131Author: Test
2132Date: Today
2133
2134    Message
2135
2136diff --git a/test.rs b/test.rs
2137index 123..456 789
2138////////////////////////////////////////////////////////////////////////////////
2139// First group
2140////////////////////////////////////////////////////////////////////////////////
2141--- a/test.rs
2142+++ b/test.rs
2143@@ -1,3 +1,4 @@
2144 fn main() {
2145+    code();
2146 }
2147"#;
2148
2149        let result = generate_evaluation_example_from_ordered_commit(
2150            commit,
2151            "",
2152            "hash",
2153            Some(SplitPoint::Index(1)),
2154            Some(1),
2155            None,
2156        );
2157
2158        assert!(result.is_ok());
2159        let case = result.unwrap();
2160
2161        // The edit history should contain the group header (// lines)
2162        // but not the commit metadata
2163        assert!(!case.edit_history.contains("Author:"));
2164        assert!(!case.edit_history.contains("Date:"));
2165    }
2166
2167    #[test]
2168    fn test_service_file_detection() {
2169        assert!(is_service_file("package.json"));
2170        assert!(is_service_file("frontend/yarn.lock"));
2171        assert!(is_service_file("a/src/generated/types.pb.go"));
2172        assert!(is_service_file("b/.github/workflows/ci.yml"));
2173        assert!(is_service_file("web/node_modules/pkg/index.js"));
2174        assert!(is_service_file("dist/app.bundle.js"));
2175
2176        assert!(!is_service_file("src/main.rs"));
2177        assert!(!is_service_file("src/build.rs"));
2178        assert!(!is_service_file("Cargo.toml"));
2179    }
2180
2181    #[test]
2182    fn test_edit_starts_on_service_file() {
2183        let commit = r#"--- a/src/lib.rs
2184+++ b/src/lib.rs
2185@@ -1,1 +1,2 @@
2186 fn lib() {}
2187+pub fn added() {}
2188--- a/package-lock.json
2189+++ b/package-lock.json
2190@@ -1,1 +1,2 @@
2191 {}
2192+{"lockfileVersion": 3}
2193--- a/src/main.rs
2194+++ b/src/main.rs
2195@@ -1,1 +1,2 @@
2196 fn main() {}
2197+println!("hello");
2198"#;
2199        let patch = Patch::parse_unified_diff(commit);
2200
2201        assert!(edit_starts_on_service_file(&patch, 1));
2202        assert!(!edit_starts_on_service_file(&patch, 2));
2203    }
2204
2205    #[test]
2206    fn test_submodule_gitlink_hunk_detection() {
2207        assert!(has_submodule_gitlink_hunk(
2208            r#"diff --git a/controllers/llguidance b/controllers/llguidance
2209index 21e68b9..cadabda 160000
2210--- a/controllers/llguidance
2211+++ b/controllers/llguidance
2212@@ -1 +1 @@
2213-Subproject commit 21e68b916d4705107e1c45ea7bc927e829136258
2214+Subproject commit cadabdad21f3b81ff58b1918f8c23116b4ff7af3
2215"#
2216        ));
2217        assert!(has_submodule_gitlink_hunk(
2218            r#"--- a/controllers/derivre
2219+++ b/controllers/derivre
2220@@ -1 +1 @@
2221-Subproject commit e83d8fb3cd92d2c6dd0437e98bfa9b64d8d8284b
2222+Subproject commit fb0ba7b6307782e0d43a0ca598b237836cb6d304
2223"#
2224        ));
2225        assert!(has_submodule_gitlink_hunk(
2226            r#"diff --git a/vendor/dependency b/vendor/dependency
2227new file mode 160000
2228index 0000000..1234567
2229--- /dev/null
2230+++ b/vendor/dependency
2231"#
2232        ));
2233        assert!(!has_submodule_gitlink_hunk(
2234            r#"diff --git a/src/lib.rs b/src/lib.rs
2235index 1234567..89abcde 100644
2236--- a/src/lib.rs
2237+++ b/src/lib.rs
2238@@ -1 +1,2 @@
2239 fn lib() {}
2240+fn helper() {}
2241"#
2242        ));
2243    }
2244
2245    #[test]
2246    fn test_generate_evaluation_example_rejects_submodule_gitlink_hunk() {
2247        let commit = r#"diff --git a/controllers/llguidance b/controllers/llguidance
2248index 21e68b9..cadabda 160000
2249--- a/controllers/llguidance
2250+++ b/controllers/llguidance
2251@@ -1 +1 @@
2252-Subproject commit 21e68b916d4705107e1c45ea7bc927e829136258
2253+Subproject commit cadabdad21f3b81ff58b1918f8c23116b4ff7af3
2254"#;
2255
2256        let result = generate_evaluation_example_from_ordered_commit(
2257            commit,
2258            "https://github.com/microsoft/aici",
2259            "cadabdad21f3b81ff58b1918f8c23116b4ff7af3",
2260            None,
2261            Some(0),
2262            None,
2263        );
2264
2265        let Err(error) = result else {
2266            panic!("expected submodule/gitlink commit to be rejected");
2267        };
2268        assert!(error.to_string().contains("submodule/gitlink"));
2269    }
2270
2271    #[test]
2272    fn test_position_weight() {
2273        // High weight positions (natural pause points)
2274        assert_eq!(position_weight("foo(", 4), 10); // After '('
2275        assert_eq!(position_weight("a, b", 2), 10); // After ','
2276        assert_eq!(position_weight("x;", 2), 10); // After ';'
2277        assert_eq!(position_weight("a: b", 2), 10); // After ':'
2278        assert_eq!(position_weight("[", 1), 10); // After '['
2279        assert_eq!(position_weight("{", 1), 10); // After '{'
2280
2281        // High weight for closing brackets
2282        assert_eq!(position_weight("foo)", 4), 8); // After ')'
2283        assert_eq!(position_weight("]", 1), 8); // After ']'
2284        assert_eq!(position_weight("}", 1), 8); // After '}'
2285
2286        // High weight at end of identifier
2287        assert_eq!(position_weight("foo ", 3), 8); // End of 'foo' before space
2288        assert_eq!(position_weight("bar(", 3), 8); // End of 'bar' before '('
2289
2290        // Medium weight for operators
2291        assert_eq!(position_weight("a + b", 3), 5); // After '+'
2292        assert_eq!(position_weight("x.", 2), 5); // After '.'
2293        assert_eq!(position_weight("a=b", 2), 5); // After '='
2294
2295        // Medium weight for whitespace
2296        assert_eq!(position_weight("a ", 2), 6); // After space
2297
2298        // Low weight mid-identifier
2299        assert_eq!(position_weight("foobar", 3), 1); // Mid-identifier 'foo|bar'
2300
2301        // Edge cases
2302        assert_eq!(position_weight("", 0), 1); // Empty string
2303        assert_eq!(position_weight("a", 0), 1); // Position 0
2304    }
2305
2306    #[test]
2307    fn test_weighted_select() {
2308        // Test that weighted selection returns correct indices
2309        let weights = vec![1, 10, 1];
2310
2311        // With total weight 12, seed 0 should select index 0
2312        // seed 0 % 12 = 0, cumulative: 1 at idx 0, so returns 0
2313        assert_eq!(weighted_select(&weights, 0), 0);
2314
2315        // seed 1 % 12 = 1, cumulative: 1 at idx 0 (1 < 1 is false), 11 at idx 1 (1 < 11 is true)
2316        assert_eq!(weighted_select(&weights, 1), 1);
2317
2318        // seed 10 % 12 = 10, cumulative: 1, 11 at idx 1 (10 < 11 is true)
2319        assert_eq!(weighted_select(&weights, 10), 1);
2320
2321        // seed 11 % 12 = 11, cumulative: 1, 11 at idx 1 (11 < 11 is false), 12 at idx 2 (11 < 12 is true)
2322        assert_eq!(weighted_select(&weights, 11), 2);
2323
2324        // Empty weights should return 0
2325        let empty: Vec<u32> = vec![];
2326        assert_eq!(weighted_select(&empty, 42), 0);
2327
2328        // Single weight should always return index 0
2329        let single = vec![10];
2330        assert_eq!(weighted_select(&single, 0), 0);
2331        assert_eq!(weighted_select(&single, 100), 0);
2332    }
2333
2334    #[test]
2335    fn test_weighted_split_prefers_natural_boundaries() {
2336        // Test that with different seeds, weighted selection tends to prefer
2337        // positions after punctuation over mid-identifier positions
2338        let text_with_punctuation = "foo(bar, baz)";
2339        let text_mid_identifier = "foobar";
2340
2341        // Position after '(' should have high weight
2342        let weight_after_paren = position_weight(text_with_punctuation, 4);
2343        // Position after ',' should have high weight
2344        let weight_after_comma = position_weight(text_with_punctuation, 8);
2345        // Position mid-identifier should have low weight
2346        let weight_mid_ident = position_weight(text_mid_identifier, 3);
2347
2348        assert!(
2349            weight_after_paren > weight_mid_ident,
2350            "After '(' ({}) should be weighted higher than mid-identifier ({})",
2351            weight_after_paren,
2352            weight_mid_ident
2353        );
2354        assert!(
2355            weight_after_comma > weight_mid_ident,
2356            "After ',' ({}) should be weighted higher than mid-identifier ({})",
2357            weight_after_comma,
2358            weight_mid_ident
2359        );
2360    }
2361
2362    #[test]
2363    fn test_imitate_human_edits_pure_insertion() {
2364        // Source patch is empty (no edits yet)
2365        // Target patch has a pure insertion (adding a new line)
2366        let source = r#"--- a/test.rs
2367+++ b/test.rs
2368@@ -1,2 +1,2 @@
2369 fn main() {
2370 }
2371"#;
2372        let target = r#"--- a/test.rs
2373+++ b/test.rs
2374@@ -1,2 +1,3 @@
2375 fn main() {
2376+    println!("debug");
2377 }
2378"#;
2379
2380        let (new_src, new_tgt, cursor) = imitate_human_edits(source, target, 42);
2381
2382        // Should have transformed the patches
2383        assert_ne!(
2384            new_src, source,
2385            "Source should be modified for pure insertion"
2386        );
2387        assert_ne!(
2388            new_tgt, target,
2389            "Target should be modified for pure insertion"
2390        );
2391        assert!(cursor.is_some(), "Cursor should be set");
2392
2393        // Source should now have a partial addition
2394        let src_patch = Patch::parse_unified_diff(&new_src);
2395        assert!(
2396            src_patch.stats().added > 0,
2397            "Source should have added lines"
2398        );
2399
2400        // Target should have both a deletion (of partial) and addition (of full)
2401        let tgt_patch = Patch::parse_unified_diff(&new_tgt);
2402        assert!(
2403            tgt_patch.stats().removed > 0,
2404            "Target should have removed lines (partial)"
2405        );
2406        assert!(
2407            tgt_patch.stats().added > 0,
2408            "Target should have added lines (full)"
2409        );
2410
2411        // The cursor should be in test.rs
2412        let cursor = cursor.unwrap();
2413        assert_eq!(cursor.file, "test.rs");
2414    }
2415
2416    #[test]
2417    fn test_imitate_human_edits_pure_insertion_empty_source() {
2418        // Source patch has no hunks at all
2419        let source = "";
2420        let target = r#"--- a/test.rs
2421+++ b/test.rs
2422@@ -1,2 +1,3 @@
2423 fn main() {
2424+    println!("hello");
2425 }
2426"#;
2427
2428        let (new_src, _new_tgt, cursor) = imitate_human_edits(source, target, 123);
2429
2430        // Should have created a source patch with partial insertion
2431        assert!(!new_src.is_empty(), "Source should not be empty");
2432        assert!(cursor.is_some(), "Cursor should be set");
2433
2434        let src_patch = Patch::parse_unified_diff(&new_src);
2435        assert!(
2436            src_patch.stats().added > 0,
2437            "Source should have added lines"
2438        );
2439    }
2440
2441    #[test]
2442    fn test_imitate_human_edits_pure_insertion_intermediate_content() {
2443        // Verify the actual intermediate content is a realistic partial typing state
2444        let source = "";
2445        let target = r#"--- a/test.rs
2446+++ b/test.rs
2447@@ -1,2 +1,3 @@
2448 fn main() {
2449+    println!("hello world");
2450 }
2451"#;
2452
2453        // Test with multiple seeds to see different split points
2454        let mut found_partial = false;
2455        for seed in 1..=50 {
2456            let (new_src, new_tgt, cursor) = imitate_human_edits(source, target, seed);
2457
2458            if cursor.is_some() {
2459                let src_patch = Patch::parse_unified_diff(&new_src);
2460                let tgt_patch = Patch::parse_unified_diff(&new_tgt);
2461
2462                // Find the added line in source
2463                for hunk in &src_patch.hunks {
2464                    for line in &hunk.lines {
2465                        if let PatchLine::Addition(content) = line {
2466                            // The partial line should be a prefix of the full line
2467                            let full_line = "    println!(\"hello world\");";
2468                            if content != full_line && full_line.starts_with(content) {
2469                                found_partial = true;
2470
2471                                // Verify target has the partial as deletion
2472                                let mut has_deletion = false;
2473                                for tgt_hunk in &tgt_patch.hunks {
2474                                    for tgt_line in &tgt_hunk.lines {
2475                                        if let PatchLine::Deletion(del_content) = tgt_line {
2476                                            if del_content == content {
2477                                                has_deletion = true;
2478                                            }
2479                                        }
2480                                    }
2481                                }
2482                                assert!(
2483                                    has_deletion,
2484                                    "Target should have deletion of partial line"
2485                                );
2486                            }
2487                        }
2488                    }
2489                }
2490            }
2491        }
2492
2493        assert!(
2494            found_partial,
2495            "At least one seed should produce a partial intermediate state"
2496        );
2497    }
2498
2499    #[test]
2500    fn test_imitate_human_edits_inserts_after_last_source_edit() {
2501        // Regression test: intermediate content should appear after the last edit
2502        // in the source patch, not at the position of the first target edit.
2503        // This ensures the diff output correctly imitates human typing order.
2504        //
2505        // The bug was: when source has edits and target has a pure insertion,
2506        // the intermediate content was inserted at tgt_edit_loc.line_index_within_hunk
2507        // (position of first target edit) instead of after the last source edit.
2508        //
2509        // Source patch has edits at lines 1-4, target has a new edit at line 10
2510        // (different location to avoid the "same line" early return)
2511        let source = r#"--- a/test.py
2512+++ b/test.py
2513@@ -1,4 +1,5 @@
2514+import foo
2515 import bar
2516-import old
2517 import baz
2518+import qux
2519"#;
2520        // Target has a pure insertion at a different line (line 10, not overlapping with source)
2521        let target = r#"--- a/test.py
2522+++ b/test.py
2523@@ -10,3 +10,4 @@
2524 def main():
2525+    print("hello world")
2526     pass
2527"#;
2528
2529        // Use a seed that produces a partial result
2530        let (new_src, _new_tgt, cursor) = imitate_human_edits(source, target, 42);
2531
2532        // The function should produce a modified patch
2533        assert!(cursor.is_some(), "Should produce intermediate state");
2534
2535        let src_patch = Patch::parse_unified_diff(&new_src);
2536        let all_additions: Vec<_> = src_patch
2537            .hunks
2538            .iter()
2539            .flat_map(|h| h.lines.iter())
2540            .filter_map(|l| match l {
2541                PatchLine::Addition(s) => Some(s.as_str()),
2542                _ => None,
2543            })
2544            .collect();
2545
2546        // The intermediate content (partial 'print("hello world")') should be
2547        // the LAST addition, appearing after "+import qux" (the last source edit)
2548        let last_addition = all_additions.last().expect("Should have additions");
2549        assert!(
2550            last_addition.trim_start().starts_with("pr"),
2551            "Intermediate content should be the last addition (partial 'print'), but last was: {:?}",
2552            last_addition
2553        );
2554
2555        // Verify the original source edits are still in order before the intermediate
2556        let foo_pos = all_additions.iter().position(|s| *s == "import foo");
2557        let qux_pos = all_additions.iter().position(|s| *s == "import qux");
2558        let intermediate_pos = all_additions
2559            .iter()
2560            .position(|s| s.trim_start().starts_with("pr"));
2561
2562        assert!(foo_pos.is_some(), "Should have 'import foo'");
2563        assert!(qux_pos.is_some(), "Should have 'import qux'");
2564        assert!(
2565            intermediate_pos.is_some(),
2566            "Should have intermediate content"
2567        );
2568
2569        assert!(
2570            foo_pos < qux_pos && qux_pos < intermediate_pos,
2571            "Order should be: foo < qux < intermediate. Got foo={:?}, qux={:?}, intermediate={:?}",
2572            foo_pos,
2573            qux_pos,
2574            intermediate_pos
2575        );
2576    }
2577
2578    #[test]
2579    fn test_cursor_excerpt_with_multibyte_utf8() {
2580        // Test that cursor excerpt handles multi-byte UTF-8 characters correctly
2581        // The Chinese character '第' is 3 bytes (0..3)
2582        let cursor = CursorPosition {
2583            file: "test.md".to_string(),
2584            line: 1,
2585            column: 1, // Byte index 1 is inside '第' (bytes 0..3)
2586            line_length: 80,
2587        };
2588
2589        let source_patch = r#"--- a/test.md
2590+++ b/test.md
2591@@ -1,1 +1,1 @@
2592+第 14 章 Flask 工作原理与机制解析**
2593"#;
2594
2595        let target_patch = "";
2596
2597        // This should not panic even though column=1 is not a char boundary
2598        let result = get_cursor_excerpt(&cursor, source_patch, target_patch);
2599
2600        // The function should handle the invalid byte index gracefully
2601        if let Some(excerpt) = result {
2602            assert!(
2603                excerpt.contains("<|user_cursor|>"),
2604                "Cursor excerpt should contain marker"
2605            );
2606            // The marker should be placed at a valid character boundary
2607            // (either at the start or after '第')
2608        }
2609    }
2610}
2611
Served at tenant.openagents/omega Member data and write actions are omitted.