Skip to repository content970 lines · 32.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:52:05.154Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
synthesize.rs
1use crate::{
2 anthropic_client::PlainLlmClient,
3 git::{ensure_repo_cloned, run_git},
4 paths::{FAILED_EXAMPLES_DIR, LATEST_FAILED_EXAMPLES_DIR, SYNTHESIZE_STATE_FILE},
5 progress::{InfoStyle, Progress, Step, StepProgress},
6};
7use anthropic::ResponseContent;
8use anyhow::{Context as _, Result};
9use chrono::Local;
10use collections::{HashMap, HashSet};
11use edit_prediction::{
12 data_collection::format_cursor_excerpt,
13 example_spec::ExampleSpec,
14 udiff::{apply_diff_to_string, edits_for_diff},
15};
16use futures::stream::{FuturesUnordered, StreamExt};
17use indoc::indoc;
18use serde::{Deserialize, Serialize};
19use std::{
20 path::{Path, PathBuf},
21 sync::Arc,
22};
23
24#[derive(Debug, Clone)]
25pub struct SynthesizeConfig {
26 pub repo_urls: Vec<String>,
27 /// Number of examples to generate per repository
28 pub count: usize,
29 pub max_commits: usize,
30 pub output_dir: PathBuf,
31 pub fresh: bool,
32}
33
34#[derive(Debug, Default, Serialize, Deserialize)]
35struct SynthesizeState {
36 repositories: HashMap<String, RepoState>,
37}
38
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct RepoState {
41 processed_commits: HashSet<String>,
42 examples_generated: usize,
43}
44
45impl SynthesizeState {
46 fn load() -> Self {
47 if SYNTHESIZE_STATE_FILE.exists() {
48 std::fs::read_to_string(&*SYNTHESIZE_STATE_FILE)
49 .ok()
50 .and_then(|s| serde_json::from_str(&s).ok())
51 .unwrap_or_default()
52 } else {
53 Self::default()
54 }
55 }
56
57 fn save(&self) -> Result<()> {
58 let content = serde_json::to_string_pretty(self)?;
59 std::fs::write(&*SYNTHESIZE_STATE_FILE, content)?;
60 Ok(())
61 }
62
63 fn take_repo_state(&mut self, repo_url: &str) -> RepoState {
64 self.repositories.remove(repo_url).unwrap_or_default()
65 }
66
67 fn merge_repo_state(&mut self, repo_url: String, repo_state: RepoState) {
68 self.repositories.insert(repo_url, repo_state);
69 }
70}
71
72impl RepoState {
73 fn is_processed(&self, commit_sha: &str) -> bool {
74 self.processed_commits.contains(commit_sha)
75 }
76
77 fn mark_processed(&mut self, commit_sha: &str, examples_count: usize) {
78 self.processed_commits.insert(commit_sha.to_string());
79 self.examples_generated += examples_count;
80 }
81}
82
83#[derive(Debug)]
84struct CommitInfo {
85 sha: String,
86 parent_sha: String,
87 message: String,
88 diff: String,
89 expanded_diff: String,
90}
91
92/// Claude's response parsed into structured form
93#[derive(Debug)]
94struct ClaudeResponse {
95 name: String,
96 reasoning: String,
97 edit_history_hunks: Vec<String>,
98 expected_patch_hunks: Vec<String>,
99}
100
101pub async fn run_synthesize(config: SynthesizeConfig) -> Result<()> {
102 let mut state = if config.fresh {
103 SynthesizeState::default()
104 } else {
105 SynthesizeState::load()
106 };
107
108 std::fs::create_dir_all(&config.output_dir)?;
109 std::fs::create_dir_all(&*FAILED_EXAMPLES_DIR)?;
110
111 // Create "latest_failed" symlink pointing to this run's failed directory
112 if LATEST_FAILED_EXAMPLES_DIR.is_symlink() {
113 std::fs::remove_file(&*LATEST_FAILED_EXAMPLES_DIR)?;
114 }
115 #[cfg(unix)]
116 std::os::unix::fs::symlink(&*FAILED_EXAMPLES_DIR, &*LATEST_FAILED_EXAMPLES_DIR)?;
117 #[cfg(windows)]
118 std::os::windows::fs::symlink_dir(&*FAILED_EXAMPLES_DIR, &*LATEST_FAILED_EXAMPLES_DIR)?;
119
120 let progress = Progress::global();
121 let total_examples = config.count * config.repo_urls.len();
122 progress.set_total_examples(total_examples);
123
124 let client = Arc::new(PlainLlmClient::new()?);
125 let config = Arc::new(config);
126
127 let mut futures: FuturesUnordered<_> = config
128 .repo_urls
129 .iter()
130 .map(|repo_url| {
131 let client = client.clone();
132 let repo_state = state.take_repo_state(repo_url);
133 let config = config.clone();
134 let repo_url = repo_url.clone();
135 async move {
136 let result = synthesize_repo(&client, repo_state, &config, &repo_url).await;
137 (repo_url, result)
138 }
139 })
140 .collect();
141
142 let mut errors = Vec::new();
143 while let Some((repo_url, result)) = futures.next().await {
144 match result {
145 Ok(repo_state) => {
146 state.merge_repo_state(repo_url, repo_state);
147 }
148 Err(e) => {
149 errors.push(e);
150 }
151 }
152 }
153
154 state.save()?;
155
156 progress.finalize();
157
158 if let Some(first_error) = errors.into_iter().next() {
159 return Err(first_error);
160 }
161
162 Ok(())
163}
164
165async fn synthesize_repo(
166 client: &PlainLlmClient,
167 mut repo_state: RepoState,
168 config: &SynthesizeConfig,
169 repo_url: &str,
170) -> Result<RepoState> {
171 let progress = Progress::global();
172 let batch_size = config.max_commits;
173
174 let clone_progress = progress.start(Step::Synthesize, &format!("clone {}", repo_url));
175 let repo_path = ensure_repo_cloned(repo_url).await?;
176 drop(clone_progress);
177
178 let mut examples_generated = 0;
179 let mut commits_skipped = 0;
180
181 'outer: loop {
182 let list_progress = progress.start(
183 Step::Synthesize,
184 &format!("{}: list-commits", repo_name_from_url(repo_url)),
185 );
186 let commits = list_commits(&repo_path, batch_size, commits_skipped).await?;
187 drop(list_progress);
188
189 if commits.is_empty() {
190 break;
191 }
192
193 commits_skipped += commits.len();
194
195 for commit in commits {
196 if examples_generated >= config.count {
197 break 'outer;
198 }
199
200 if !config.fresh && repo_state.is_processed(&commit.sha) {
201 continue;
202 }
203
204 if should_skip_commit(&commit) {
205 continue;
206 }
207
208 let repo_name = repo_name_from_url(repo_url);
209 let commit_label = format!(
210 "{}: {} {}",
211 repo_name,
212 &commit.sha[..8],
213 truncate_message(&commit.message, 40)
214 );
215 let step_progress = Arc::new(progress.start(Step::Synthesize, &commit_label));
216
217 // Single Claude call to identify and copy hunks
218 step_progress.set_substatus("analyzing...");
219 let claude_response =
220 match analyze_commit(client, repo_url, &commit, step_progress.clone()).await {
221 Ok(Some(response)) => response,
222 Ok(None) => {
223 step_progress.set_info("no pattern", InfoStyle::Normal);
224 repo_state.mark_processed(&commit.sha, 0);
225 continue;
226 }
227 Err(e) => {
228 step_progress.set_info(format!("error: {:?}", e), InfoStyle::Warning);
229 repo_state.mark_processed(&commit.sha, 0);
230 continue;
231 }
232 };
233
234 // Validate and build the example
235 step_progress.set_substatus("validating...");
236 match build_example(repo_url, &commit, &repo_path, &claude_response).await {
237 Ok(spec) => {
238 let timestamp = Local::now().format("%Y-%m-%d--%H-%M-%S");
239 let filename = format!("{}--{}.md", repo_name, timestamp);
240 let path = config.output_dir.join(&filename);
241 std::fs::write(&path, spec.to_markdown())?;
242 examples_generated += 1;
243 step_progress.set_info(filename, InfoStyle::Normal);
244 }
245 Err(rejection_reason) => {
246 log::debug!("Example rejected: {}", rejection_reason);
247 let timestamp = Local::now().format("%Y-%m-%d--%H-%M-%S%.3f");
248 let filename = format!("{}--{}.md", repo_name, timestamp);
249 let path = FAILED_EXAMPLES_DIR.join(&filename);
250 let content = format_rejected_example(&claude_response, &rejection_reason);
251 if let Err(e) = std::fs::write(&path, content) {
252 log::warn!("Failed to write rejected example: {:?}", e);
253 }
254 step_progress.set_info(format!("rejected: {}", filename), InfoStyle::Warning);
255 }
256 }
257
258 repo_state.mark_processed(&commit.sha, 1);
259 }
260 }
261
262 Ok(repo_state)
263}
264
265fn repo_name_from_url(url: &str) -> String {
266 url.rsplit('/')
267 .next()
268 .unwrap_or(url)
269 .trim_end_matches(".git")
270 .to_string()
271}
272
273fn truncate_message(msg: &str, max_len: usize) -> String {
274 let first_line = msg.lines().next().unwrap_or("");
275 if first_line.len() <= max_len {
276 first_line.to_string()
277 } else {
278 format!("{}...", &first_line[..max_len - 3])
279 }
280}
281
282fn should_skip_commit(commit: &CommitInfo) -> bool {
283 let lines_changed = commit
284 .diff
285 .lines()
286 .filter(|l| l.starts_with('+') || l.starts_with('-'))
287 .count();
288 lines_changed < 30
289 || lines_changed > 1000
290 || is_non_code_commit(commit)
291 || is_rename_commit(commit)
292}
293
294fn is_non_code_commit(commit: &CommitInfo) -> bool {
295 let non_code_extensions = [
296 ".md", ".txt", ".json", ".yaml", ".yml", ".toml", ".lock", ".svg", ".png", ".jpg", ".gif",
297 ".ico", ".woff", ".ttf", ".eot",
298 ];
299
300 let diff_files: Vec<&str> = commit
301 .diff
302 .lines()
303 .filter(|l| l.starts_with("+++ b/") || l.starts_with("--- a/"))
304 .filter_map(|l| {
305 l.strip_prefix("+++ b/")
306 .or_else(|| l.strip_prefix("--- a/"))
307 })
308 .collect();
309
310 if diff_files.is_empty() {
311 return false;
312 }
313
314 diff_files
315 .iter()
316 .all(|f| non_code_extensions.iter().any(|ext| f.ends_with(ext)))
317}
318
319fn is_rename_commit(commit: &CommitInfo) -> bool {
320 commit.diff.contains("similarity index")
321 || commit.diff.contains("rename from")
322 || commit.diff.contains("rename to")
323}
324
325async fn list_commits(
326 repo_path: &Path,
327 max_commits: usize,
328 skip: usize,
329) -> Result<Vec<CommitInfo>> {
330 let output = run_git(
331 repo_path,
332 &[
333 "log",
334 "--no-merges",
335 &format!("--skip={}", skip),
336 &format!("-{}", max_commits),
337 "--format=%H|%P|%s",
338 ],
339 )
340 .await?;
341
342 let mut commits = Vec::new();
343 for line in output.lines() {
344 let parts: Vec<&str> = line.splitn(3, '|').collect();
345 if parts.len() < 3 {
346 continue;
347 }
348 let sha = parts[0].to_string();
349 let parent_sha = parts[1].split_whitespace().next().unwrap_or("").to_string();
350 if parent_sha.is_empty() {
351 continue;
352 }
353
354 // Get standard diff (for skip checks)
355 let diff = run_git(repo_path, &["show", "--format=", &sha])
356 .await
357 .unwrap_or_default();
358
359 // Get expanded diff with 30 lines of context
360 let expanded_diff = run_git(repo_path, &["show", "-U30", "--format=", &sha])
361 .await
362 .unwrap_or_default();
363
364 commits.push(CommitInfo {
365 sha,
366 parent_sha,
367 message: parts[2].to_string(),
368 diff,
369 expanded_diff,
370 });
371 }
372
373 Ok(commits)
374}
375
376fn build_prompt(repo_url: &str, commit: &CommitInfo) -> String {
377 format!(
378 indoc! {r#"
379 You are analyzing a git commit to construct a realistic edit prediction example.
380
381 Your goal is to tell the story of a programmer's editing session: what sequence
382 of changes did they make, and what change logically comes next? We use these examples
383 to train a model to predict edits, so the quality of the EDIT HISTORY is what matters most.
384
385 An edit prediction example consists of:
386 1. **Edit History**: 2-6 hunks showing what the programmer did BEFORE making the expected patch.
387 This is the most important part - it must tell a coherent story of the changes leading up to the prediction.
388 2. **Expected Patch**: One small hunk that logically follows from the edit history.
389
390 Both single-file and multi-file patterns are acceptable.
391
392 ## What Makes a Good Example
393
394 The edit history should read like a story: "First the programmer changed X, then Y, then Z, and now they need to change W."
395
396 GOOD examples (rich sequences with 3+ steps):
397 - Removing a parameter: docstring update → constructor change → field removal → (predict) usage site update
398 - Adding a feature: type definition → first usage → second usage → (predict) third usage
399 - Bug fix pattern: fix in file A → fix in file B → fix in file C → (predict) fix in file D
400
401 BAD examples (respond NO_PATTERN):
402 - Commits where all changes are independent (no narrative thread)
403 - Simple find-and-replace (renaming, version bumps)
404 - Documentation-only or config-only changes
405 - Changes where you can only find 1-2 hunks for the edit history
406
407 ## Commit Information
408
409 Repository: {repo_url}
410 Commit: {sha}
411 Message: {message}
412
413 ## Diff (30 lines context)
414
415 ```diff
416 {expanded_diff}
417 ```
418
419 ## Your Task
420
421 First, THINK through whether this commit can support a good example:
422
423 1. What is the high-level pattern in this commit?
424 2. Can you identify at least 3 related hunks (2 or more for edit history + 1 for expected patch)?
425 3. What would be the narrative? (First... then... then... finally predict...)
426 4. Which specific hunk should be the expected patch (the "punchline")?
427
428 If you cannot construct a coherent 3+ hunk story, respond with just:
429 NO_PATTERN: <brief reason>
430
431 If you CAN construct a good example, respond in this format:
432
433 ANALYSIS:
434 Pattern: <one sentence describing the pattern>
435 Steps:
436 1. <file:line-range> - <what this hunk does>
437 2. <file:line-range> - <what this hunk does>
438 3. <file:line-range> - <what this hunk does>
439 4. [EXPECTED PATCH] <file:line-range> - <what this hunk does>
440
441 NAME: <short description, like a commit message, under 60 chars>
442
443 EDIT_HISTORY:
444
445 Hunk 1:
446 ```diff
447 --- a/src/models/user.py
448 +++ b/src/models/user.py
449 @@ -15,7 +15,6 @@ class User:
450 """A user in the system.
451
452 Attributes:
453 - email: The user's email address.
454 name: The user's display name.
455 """
456 ```
457
458 Hunk 2:
459 ```diff
460 --- a/src/models/user.py
461 +++ b/src/models/user.py
462 @@ -25,10 +24,9 @@ class User:
463 def __init__(
464 self,
465 name: str,
466 - email: str,
467 created_at: datetime,
468 ):
469 self.name = name
470 - self.email = email
471 self.created_at = created_at
472 ```
473
474 Hunk 3:
475 ```diff
476 --- a/src/api/handlers.py
477 +++ b/src/api/handlers.py
478 @@ -42,7 +42,6 @@ def create_user(request):
479 data = request.json()
480 user = User(
481 name=data["name"],
482 - email=data["email"],
483 created_at=datetime.now(),
484 )
485 return user.save()
486 ```
487
488 EXPECTED_PATCH:
489 ```diff
490 --- a/src/api/handlers.py
491 +++ b/src/api/handlers.py
492 @@ -58,7 +57,6 @@ def update_user(request, user_id):
493 user = User.get(user_id)
494 user.name = data.get("name", user.name)
495 - user.email = data.get("email", user.email)
496 user.save()
497 return user
498 ```
499
500 ## Requirements for the diffs
501
502 Edit history:
503 - MUST have 3-6 hunks (if you cannot find 3+, respond NO_PATTERN instead)
504 - Each hunk needs file headers (--- a/path and +++ b/path)
505 - Hunks must be valid unified diffs that apply to the parent commit
506 - Order hunks as a programmer would naturally make the changes
507
508 Expected patch:
509 - Must be a SINGLE hunk from a SINGLE file
510 - Must be SMALL: 1-15 changed lines (not counting context)
511 - Must be clearly predictable from the edit history narrative
512 "#},
513 repo_url = repo_url,
514 sha = commit.sha,
515 message = commit.message,
516 expanded_diff = commit.expanded_diff,
517 )
518}
519
520async fn analyze_commit(
521 client: &PlainLlmClient,
522 repo_url: &str,
523 commit: &CommitInfo,
524 step_progress: Arc<StepProgress>,
525) -> Result<Option<ClaudeResponse>> {
526 use anthropic::{Message, RequestContent, Role};
527
528 let prompt = build_prompt(repo_url, commit);
529 let messages = vec![Message {
530 role: Role::User,
531 content: vec![RequestContent::Text {
532 text: prompt,
533 cache_control: None,
534 }],
535 }];
536
537 let response = client
538 .generate_streaming("claude-sonnet-4-5", 8192, messages, |chars, _text| {
539 step_progress.set_substatus(format!("analyzing: {:.1}K", chars as f64 / 1000.0));
540 })
541 .await?;
542
543 // Extract text content from response
544 let response_text: String = response
545 .content
546 .iter()
547 .filter_map(|block| {
548 if let ResponseContent::Text { text } = block {
549 Some(text.as_str())
550 } else {
551 None
552 }
553 })
554 .collect::<Vec<_>>()
555 .join("\n");
556
557 parse_claude_response(&response_text)
558}
559
560fn parse_claude_response(response: &str) -> Result<Option<ClaudeResponse>> {
561 // Check for NO_PATTERN
562 if response.contains("NO_PATTERN:") {
563 return Ok(None);
564 }
565
566 // Parse NAME
567 let name = response
568 .lines()
569 .find(|l| l.starts_with("NAME:"))
570 .map(|l| l.strip_prefix("NAME:").unwrap_or("").trim().to_string())
571 .unwrap_or_else(|| "unnamed example".to_string());
572
573 // Parse ANALYSIS section (Claude's planning) - this is the primary reasoning
574 let reasoning = extract_section(
575 response,
576 "ANALYSIS:",
577 &["NAME:", "REASONING:", "EDIT_HISTORY:", "EXPECTED_PATCH:"],
578 )
579 .unwrap_or_default();
580
581 // Parse EDIT_HISTORY diff block
582 let edit_history_hunks = extract_diff_block(response, "EDIT_HISTORY:")?;
583
584 // Parse EXPECTED_PATCH diff block
585 let expected_patch_hunks = extract_diff_block(response, "EXPECTED_PATCH:")?;
586
587 if edit_history_hunks.is_empty() {
588 anyhow::bail!("No edit history hunks found in response");
589 }
590 if expected_patch_hunks.is_empty() {
591 anyhow::bail!("No expected patch hunks found in response");
592 }
593
594 Ok(Some(ClaudeResponse {
595 name,
596 reasoning,
597 edit_history_hunks,
598 expected_patch_hunks,
599 }))
600}
601
602fn extract_section(text: &str, start_marker: &str, end_markers: &[&str]) -> Option<String> {
603 let start_idx = text.find(start_marker)?;
604 let content_start = start_idx + start_marker.len();
605
606 let end_idx = end_markers
607 .iter()
608 .filter_map(|marker| text[content_start..].find(marker))
609 .min()
610 .map(|idx| content_start + idx)
611 .unwrap_or(text.len());
612
613 Some(text[content_start..end_idx].trim().to_string())
614}
615
616fn extract_diff_block(text: &str, section_marker: &str) -> Result<Vec<String>> {
617 let section_start = text
618 .find(section_marker)
619 .context(format!("Section {} not found", section_marker))?;
620
621 let after_marker = &text[section_start + section_marker.len()..];
622
623 // Find where the next major section starts (to bound our search)
624 let section_end = ["EXPECTED_PATCH:", "## "]
625 .iter()
626 .filter(|&&m| m != section_marker)
627 .filter_map(|marker| after_marker.find(marker))
628 .min()
629 .unwrap_or(after_marker.len());
630
631 let section_content = &after_marker[..section_end];
632
633 // Collect all ```diff blocks in this section
634 let mut hunks = Vec::new();
635 let mut search_start = 0;
636
637 while let Some(diff_start) = section_content[search_start..].find("```diff") {
638 let abs_diff_start = search_start + diff_start;
639 let block_content_start = section_content[abs_diff_start..]
640 .find('\n')
641 .map(|i| abs_diff_start + i + 1)
642 .unwrap_or(abs_diff_start);
643
644 if let Some(block_end_rel) = section_content[block_content_start..].find("```") {
645 let block_end = block_content_start + block_end_rel;
646 let diff_content = section_content[block_content_start..block_end].trim();
647
648 // Split this block into hunks (in case multiple hunks in one block)
649 hunks.extend(split_into_hunks(diff_content));
650
651 search_start = block_end + 3;
652 } else {
653 break;
654 }
655 }
656
657 if hunks.is_empty() {
658 anyhow::bail!("No diff blocks found in section {}", section_marker);
659 }
660
661 Ok(hunks)
662}
663
664/// Split a diff block into individual hunks, preserving file headers
665fn split_into_hunks(diff: &str) -> Vec<String> {
666 let mut hunks = Vec::new();
667 let mut current_file_header: Option<String> = None;
668 let mut current_hunk: Vec<String> = Vec::new();
669 let mut in_hunk = false;
670
671 for line in diff.lines() {
672 if line.starts_with("--- a/") || line.starts_with("--- /") {
673 // Start of file header - flush previous hunk
674 if in_hunk && !current_hunk.is_empty() {
675 let mut hunk_text = String::new();
676 if let Some(ref header) = current_file_header {
677 hunk_text.push_str(header);
678 hunk_text.push('\n');
679 }
680 hunk_text.push_str(¤t_hunk.join("\n"));
681 hunks.push(hunk_text);
682 current_hunk.clear();
683 }
684 current_file_header = Some(line.to_string());
685 in_hunk = false;
686 } else if line.starts_with("+++ b/") || line.starts_with("+++ /") {
687 if let Some(ref mut header) = current_file_header {
688 header.push('\n');
689 header.push_str(line);
690 }
691 } else if line.starts_with("@@ ") {
692 // New hunk - flush previous
693 if in_hunk && !current_hunk.is_empty() {
694 let mut hunk_text = String::new();
695 if let Some(ref header) = current_file_header {
696 hunk_text.push_str(header);
697 hunk_text.push('\n');
698 }
699 hunk_text.push_str(¤t_hunk.join("\n"));
700 hunks.push(hunk_text);
701 current_hunk.clear();
702 }
703 current_hunk.push(line.to_string());
704 in_hunk = true;
705 } else if in_hunk {
706 current_hunk.push(line.to_string());
707 }
708 }
709
710 // Flush final hunk
711 if !current_hunk.is_empty() {
712 let mut hunk_text = String::new();
713 if let Some(ref header) = current_file_header {
714 hunk_text.push_str(header);
715 hunk_text.push('\n');
716 }
717 hunk_text.push_str(¤t_hunk.join("\n"));
718 hunks.push(hunk_text);
719 }
720
721 hunks
722}
723
724/// Validate Claude's output by applying diffs and build the ExampleSpec
725async fn build_example(
726 repo_url: &str,
727 commit: &CommitInfo,
728 repo_path: &Path,
729 response: &ClaudeResponse,
730) -> Result<ExampleSpec, String> {
731 // Validate expected patch hunks
732 if response.expected_patch_hunks.len() != 1 {
733 return Err(format!(
734 "Expected exactly 1 expected patch hunk, got {}",
735 response.expected_patch_hunks.len()
736 ));
737 }
738
739 // Parse the expected patch to determine cursor file
740 let expected_patch = &response.expected_patch_hunks[0];
741 let cursor_file = extract_file_from_hunk(expected_patch)
742 .ok_or_else(|| "Could not determine file from expected patch".to_string())?;
743
744 // Get the file content before the commit
745 let before_content = run_git(
746 repo_path,
747 &["show", &format!("{}^:{}", commit.sha, cursor_file)],
748 )
749 .await
750 .map_err(|e| format!("Failed to get file content for {}: {}", cursor_file, e))?;
751
752 // Build edit history diff from Claude's hunks
753 let edit_history = response.edit_history_hunks.join("\n");
754
755 // Apply edit history to get intermediate state (validates edit history)
756 let intermediate_state =
757 apply_edit_history_to_content(&before_content, &edit_history, &cursor_file)?;
758
759 // Validate expected patch applies to intermediate state
760 let expected_patch_with_header = ensure_diff_header(expected_patch, &cursor_file);
761 apply_diff_to_string(&expected_patch_with_header, &intermediate_state)
762 .map_err(|e| format!("Expected patch failed to apply: {}", e))?;
763
764 // Find where the expected patch edits would apply in the intermediate state
765 let edits = edits_for_diff(&intermediate_state, &expected_patch_with_header)
766 .map_err(|e| format!("Failed to parse expected patch: {}", e))?;
767 if edits.is_empty() {
768 return Err(
769 "Could not locate expected patch in file (context not found or ambiguous)".to_string(),
770 );
771 }
772
773 // Use the start of the first edit for cursor positioning
774 let cursor_byte_offset = edits[0].0.start;
775
776 // Extract excerpt around the edit location
777 let (excerpt, cursor_offset) = extract_cursor_excerpt(&intermediate_state, cursor_byte_offset)?;
778
779 // Build the ExampleSpec and use set_cursor_excerpt to format with comment marker
780 let comment_prefix = line_comment_prefix(&cursor_file);
781 let reasoning_with_source = format!(
782 "Source commit: {} ({})\n\n{}",
783 commit.sha,
784 truncate_message(&commit.message, 60),
785 response.reasoning
786 );
787 let spec = ExampleSpec {
788 name: response.name.clone(),
789 repository_url: repo_url.to_string(),
790 revision: commit.parent_sha.clone(),
791 tags: Vec::new(),
792 reasoning: Some(reasoning_with_source),
793 uncommitted_diff: String::new(),
794 recently_opened_files: Vec::new(),
795 recently_viewed_files: Vec::new(),
796 uncommitted_diff_contains_edit_history: false,
797 cursor_path: Arc::from(Path::new(&cursor_file)),
798 cursor_position: format_cursor_excerpt(&excerpt, cursor_offset, comment_prefix),
799 edit_history,
800 expected_patches: vec![expected_patch_with_header],
801 rejected_patch: None,
802 telemetry: None,
803 human_feedback: Vec::new(),
804 rating: None,
805 };
806
807 Ok(spec)
808}
809
810/// Extract file path from a hunk (looks for --- a/path or +++ b/path)
811fn extract_file_from_hunk(hunk: &str) -> Option<String> {
812 for line in hunk.lines() {
813 if let Some(path) = line.strip_prefix("+++ b/") {
814 return Some(path.to_string());
815 }
816 if let Some(path) = line.strip_prefix("--- a/") {
817 return Some(path.to_string());
818 }
819 }
820 None
821}
822
823/// Ensure a hunk has proper file headers
824fn ensure_diff_header(hunk: &str, file_path: &str) -> String {
825 if hunk.contains("--- a/") || hunk.contains("+++ b/") {
826 return hunk.to_string();
827 }
828 format!("--- a/{}\n+++ b/{}\n{}", file_path, file_path, hunk)
829}
830
831/// Apply edit history to file content, only if hunks affect this file
832fn apply_edit_history_to_content(
833 content: &str,
834 edit_history: &str,
835 cursor_file: &str,
836) -> Result<String, String> {
837 // Extract just the hunks for this file from the edit history
838 let file_diff = extract_file_diff_from_combined(edit_history, cursor_file);
839
840 if file_diff.is_empty() {
841 return Ok(content.to_string());
842 }
843
844 apply_diff_to_string(&file_diff, content)
845 .map_err(|e| format!("Failed to apply edit history: {}", e))
846}
847
848/// Extract hunks for a specific file from a combined diff
849fn extract_file_diff_from_combined(combined_diff: &str, target_file: &str) -> String {
850 let mut result = String::new();
851 let mut in_target_file = false;
852 let mut found_header = false;
853
854 for line in combined_diff.lines() {
855 if line.starts_with("--- a/") {
856 let file = line.strip_prefix("--- a/").unwrap_or("");
857 in_target_file = file == target_file;
858 if in_target_file {
859 result.push_str(line);
860 result.push('\n');
861 found_header = false;
862 }
863 } else if line.starts_with("+++ b/") && in_target_file {
864 result.push_str(line);
865 result.push('\n');
866 found_header = true;
867 } else if in_target_file && found_header {
868 if line.starts_with("--- a/") {
869 break;
870 }
871 result.push_str(line);
872 result.push('\n');
873 }
874 }
875
876 result
877}
878
879/// Extract a cursor position excerpt from content around a byte offset.
880/// Returns the excerpt and the cursor offset within the excerpt.
881fn extract_cursor_excerpt(
882 content: &str,
883 cursor_byte_offset: usize,
884) -> Result<(String, usize), String> {
885 // Find the line containing the cursor
886 let line_start = content[..cursor_byte_offset]
887 .rfind('\n')
888 .map(|pos| pos + 1)
889 .unwrap_or(0);
890 let line_end = content[cursor_byte_offset..]
891 .find('\n')
892 .map(|pos| cursor_byte_offset + pos)
893 .unwrap_or(content.len());
894
895 // Get context lines before
896 let lines_before: Vec<&str> = content[..line_start].lines().collect();
897 let context_before: Vec<&str> = lines_before.iter().rev().take(3).rev().cloned().collect();
898
899 // Get context lines after
900 let after_line_end = if line_end < content.len() {
901 line_end + 1
902 } else {
903 line_end
904 };
905 let context_after: Vec<&str> = content[after_line_end..].lines().take(4).collect();
906
907 // The line containing the cursor
908 let cursor_line = &content[line_start..line_end];
909 let cursor_column = cursor_byte_offset - line_start;
910
911 // Build the excerpt
912 let mut excerpt = String::new();
913 for line in context_before {
914 excerpt.push_str(line);
915 excerpt.push('\n');
916 }
917 // Track where cursor will be in the excerpt
918 let cursor_offset_in_excerpt = excerpt.len() + cursor_column;
919 // Line containing cursor
920 excerpt.push_str(cursor_line);
921 excerpt.push('\n');
922 for line in context_after {
923 excerpt.push_str(line);
924 excerpt.push('\n');
925 }
926
927 // Trim trailing newline
928 if excerpt.ends_with('\n') {
929 excerpt.pop();
930 }
931
932 Ok((excerpt, cursor_offset_in_excerpt))
933}
934
935/// Get the line comment prefix for a file based on its extension
936fn line_comment_prefix(file_path: &str) -> &'static str {
937 let extension = file_path.rsplit('.').next().unwrap_or("");
938 match extension {
939 "rs" | "c" | "cpp" | "cc" | "h" | "hpp" | "js" | "ts" | "tsx" | "jsx" | "go" | "java"
940 | "swift" | "kt" | "kts" | "scala" | "cs" | "m" | "mm" | "zig" | "v" | "d" => "//",
941 "py" | "rb" | "sh" | "bash" | "zsh" | "pl" | "pm" | "r" | "jl" | "yaml" | "yml"
942 | "toml" | "coffee" | "cr" | "ex" | "exs" | "elixir" => "#",
943 "lua" | "hs" | "sql" => "--",
944 "lisp" | "clj" | "cljs" | "scm" | "rkt" | "el" => ";",
945 "erl" | "hrl" => "%",
946 _ => "//",
947 }
948}
949
950fn format_rejected_example(response: &ClaudeResponse, rejection_reason: &str) -> String {
951 let mut content = String::new();
952 content.push_str("# Rejected Example\n\n");
953 content.push_str(&format!("## Name\n\n{}\n\n", response.name));
954 content.push_str(&format!("## Reasoning\n\n{}\n\n", response.reasoning));
955 content.push_str("## Edit History Hunks\n\n```diff\n");
956 for hunk in &response.edit_history_hunks {
957 content.push_str(hunk);
958 content.push_str("\n\n");
959 }
960 content.push_str("```\n\n");
961 content.push_str("## Expected Patch Hunks\n\n```diff\n");
962 for hunk in &response.expected_patch_hunks {
963 content.push_str(hunk);
964 content.push_str("\n\n");
965 }
966 content.push_str("```\n\n");
967 content.push_str(&format!("## Rejection Reason\n\n{}\n", rejection_reason));
968 content
969}
970