use kigi_tools::types::output::SearchReplaceEditDetail; use similar::{ChangeTag, TextDiff}; #[derive(Debug, Clone, PartialEq)] pub struct DiffLine { pub text: String, pub lo: usize, pub ln: usize, pub tag: ChangeTag, } pub type DiffHunk = Vec; pub fn build_diff_hunks(details: &[SearchReplaceEditDetail]) -> Vec { const MAX_CONTEXT: usize = 3; let mut hunks: Vec = Vec::new(); for edit in details { let mut diff_lines: DiffHunk = Vec::new(); let before_lines: Vec = if edit.context_before.is_empty() { vec![] } else { edit.context_before .split_inclusive('\n') .map(|s| s.to_string()) .collect() }; let n_before = before_lines.len(); for (i, line_text) in before_lines.into_iter().enumerate() { // +1 because context_before ends just before edit.old_line/new_line let from_end = n_before.saturating_sub(i + 1); let lo = edit.old_line.saturating_sub(from_end + 1); let ln = edit.new_line.saturating_sub(from_end + 1); diff_lines.push(DiffLine { text: line_text, lo, ln, tag: ChangeTag::Equal, }); } let (mut lo, mut ln) = (edit.old_line, edit.new_line); let empty_to_empty = edit.old_string.is_empty() && edit.new_string.is_empty(); let mid_file = !edit.context_before.is_empty() || !edit.context_after.is_empty(); let new_text: &str = if empty_to_empty && mid_file { // Blank-line insertion: both sides empty but a line was inserted. // Represent the blank line so TextDiff can see the insertion. // Context-free empty-to-empty (an empty file write) must NOT // fabricate a +1 insertion — it produces no diff lines at all. "\n" } else { &edit.new_string }; // When old_string/new_string start mid-line (after indentation), // the diff lines are missing the leading whitespace that context lines // have. Prepend `line_prefix` to each change on the first file line. // Once a change contains a newline, subsequent lines are full file // lines and don't need the prefix. let prefix = &edit.line_prefix; let has_prefix = !prefix.is_empty(); let mut prefix_applied_delete = false; let mut prefix_applied_insert = false; let diff = TextDiff::from_lines(edit.old_string.as_str(), new_text); for change in diff.iter_all_changes() { let tag = change.tag(); let mut text = change.value().to_owned(); if has_prefix { let needs_prefix = match tag { ChangeTag::Delete => !prefix_applied_delete, ChangeTag::Insert => !prefix_applied_insert, ChangeTag::Equal => !prefix_applied_delete && !prefix_applied_insert, }; if needs_prefix { text.insert_str(0, prefix); } match tag { ChangeTag::Delete | ChangeTag::Equal => prefix_applied_delete = true, ChangeTag::Insert => prefix_applied_insert = true, } } diff_lines.push(DiffLine { text, lo, ln, tag }); match tag { ChangeTag::Equal => { lo = lo.saturating_add(1); ln = ln.saturating_add(1); } ChangeTag::Delete => { lo = lo.saturating_add(1); } ChangeTag::Insert => { ln = ln.saturating_add(1); } } } if !edit.context_after.is_empty() { for line in edit.context_after.split_inclusive('\n') { diff_lines.push(DiffLine { text: line.to_owned(), lo, ln, tag: ChangeTag::Equal, }); lo = lo.saturating_add(1); ln = ln.saturating_add(1); } } let total_len = diff_lines.len(); let mut start; let mut end = total_len; if diff_lines.iter().all(|entry| entry.tag == ChangeTag::Equal) { start = end; // empty slice } else { let equal_before = diff_lines .iter() .take_while(|entry| entry.tag == ChangeTag::Equal) .count(); let equal_after = diff_lines .iter() .rev() .take_while(|entry| entry.tag == ChangeTag::Equal) .count(); start = equal_before.saturating_sub(MAX_CONTEXT); end = total_len.saturating_sub(equal_after.saturating_sub(MAX_CONTEXT)); } while start < end { let entry = &diff_lines[start]; if entry.tag == ChangeTag::Equal && entry.text.trim_ascii().is_empty() { start += 1; } else { break; } } while start < end { let entry = &diff_lines[end - 1]; if entry.tag == ChangeTag::Equal && entry.text.trim_ascii().is_empty() { end -= 1; } else { break; } } if start < end { hunks.push(diff_lines[start..end].to_vec()); } } hunks } /// Build diff hunks from full old/new text strings. /// /// Simpler alternative to `build_diff_hunks` when you don't have structured /// `SearchReplaceEditDetail` data — just the full before/after text. /// Used for ACP `ToolCallContent::Diff` fallback (pre-execution previews). pub fn diff_hunks_from_strings(old_text: &str, new_text: &str, start_line: usize) -> Vec { let detail = SearchReplaceEditDetail { old_string: old_text.to_owned(), old_line: start_line, new_string: new_text.to_owned(), new_line: start_line, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }; build_diff_hunks(&[detail]) } /// Stitch overlapping/adjacent hunks from coalesced same-file edits into /// unified hunks. /// /// Consecutive edits to nearby lines each carry ±context from their own file /// snapshot, so a merged block's concatenated hunks repeat context lines and /// re-show intermediate file states. Folding each hunk into the accumulated /// previous one in `ln` (post-state) coordinates: /// /// - a later edit of a shown context line swaps that Equal row for its /// `-`/`+` pair; /// - a line edited twice collapses to `-original +final` (no intermediate); /// - repeated context is dropped; new trailing rows extend the hunk. /// /// Anything the shared `ln` coordinates cannot describe truthfully bails to /// the separate-hunk fallback (today's gap-marker rendering): non-monotonic /// or non-adjacent pairs, text disagreement at a shared `ln` (line-count /// drift between snapshots makes coordinates lie), and line-count-changing /// shapes inside the covered range (pure deletes, unpaired inserts, /// multi-line replacement runs). Never render wrong content. /// /// Kept rows retain the `lo` of their own snapshot — the same convention the /// unmerged per-hunk display already uses for its old-file column. pub fn stitch_overlapping_hunks(hunks: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(hunks.len()); for hunk in hunks { if let Some(last) = out.last_mut() && let Some(stitched) = stitch_hunk_pair(last, &hunk) { *last = stitched; continue; } out.push(hunk); } out } /// Post-state (`ln`) coverage of a hunk's rendered rows (Equal/Insert). fn render_range(hunk: &DiffHunk) -> Option<(usize, usize)> { let mut range: Option<(usize, usize)> = None; for line in hunk { if line.tag == ChangeTag::Delete { continue; } range = Some(match range { None => (line.ln, line.ln), Some((min, max)) => (min.min(line.ln), max.max(line.ln)), }); } range } /// Row index in `hunk` rendering post-state line `ln` (Equal or Insert). fn render_pos(hunk: &DiffHunk, ln: usize) -> Option { hunk.iter() .position(|l| l.tag != ChangeTag::Delete && l.ln == ln) } fn trimmed(text: &str) -> &str { text.trim_end_matches(['\r', '\n']) } /// Fold `b` into `a` when both describe one contiguous post-state region; /// `None` keeps the pair as separate hunks. fn stitch_hunk_pair(a: &DiffHunk, b: &DiffHunk) -> Option { let (a_min, a_max) = render_range(a)?; let (b_min, _) = render_range(b)?; if b_min < a_min || b_min > a_max + 1 { return None; } let mut out = a.clone(); let mut max_ln = a_max; let mut i = 0; while i < b.len() { let row = &b[i]; if row.ln > max_ln { // Past the stitched coverage: `b` is the sole source for this // tail, so splice its remaining rows in verbatim (rendered rows // must stay contiguous). for rest in &b[i..] { if rest.tag != ChangeTag::Delete { if rest.ln != max_ln + 1 { return None; } max_ln = rest.ln; } out.push(rest.clone()); } break; } match row.tag { ChangeTag::Equal => { let pos = render_pos(&out, row.ln)?; if trimmed(&out[pos].text) != trimmed(&row.text) { return None; } i += 1; } ChangeTag::Delete => { // Only single-line replacement pairs keep line counts (and // therefore every later `ln`) truthful. let next = b.get(i + 1)?; if next.tag != ChangeTag::Insert || next.ln != row.ln { return None; } let pos = render_pos(&out, row.ln)?; if trimmed(&out[pos].text) != trimmed(&row.text) { return None; } match out[pos].tag { // A context line the later call edited: show its -/+ pair. ChangeTag::Equal => { out[pos] = row.clone(); out.insert(pos + 1, next.clone()); } // Same line edited twice: keep the earlier delete (if // any), drop the intermediate text, keep the final insert. ChangeTag::Insert => { out[pos] = next.clone(); } ChangeTag::Delete => unreachable!("render_pos skips deletes"), } i += 2; } // Unpaired insert inside the covered range: line-count growth. ChangeTag::Insert => return None, } } Some(out) } /// Extract diff hunks from an ACP ToolCall's raw_output or content. /// /// Tries three strategies in order: /// 1. Parse `raw_output` as `SearchReplaceOutput::EditsApplied` for structured /// per-edit hunks with context lines and accurate line numbers. /// 2. Parse `Diff.meta` as `SearchReplaceEditContextInformation` for structured /// edit details embedded in the Diff content block (set by acp_conversion). /// 3. Fall back to `ToolCallContent::Diff` old_text/new_text for full-text diff, /// using line numbers from `meta` when available (pre-execution previews). /// /// Returns `(hunks, edit_count)`. pub fn extract_edit_hunks(tc: &agent_client_protocol::ToolCall) -> (Vec, usize) { use kigi_tools::types::output::{ SearchReplaceEditContextInformation, SearchReplaceOutput, ToolOutput, }; // Strategy 1: structured edit details from raw_output (via ToolOutput wrapper) if let Some(raw) = &tc.raw_output { match serde_json::from_value::(raw.clone()) { Ok(ToolOutput::SearchReplace(SearchReplaceOutput::EditsApplied(edits))) => { let hunks = build_diff_hunks(&edits.edits.details); let count = hunks.len().max(1); return (hunks, count); } Err(e) => { tracing::warn!( tool_call_id = %tc.tool_call_id.0, error_kind = ?e.classify(), "extract_edit_hunks: raw_output failed to deserialize as ToolOutput, \ falling back to Diff.meta" ); } _ => { // raw_output is a different ToolOutput variant (not SearchReplace::EditsApplied) } } } // Strategy 2 & 3: ACP Diff content for content in &tc.content { if let agent_client_protocol::ToolCallContent::Diff(diff) = content { // Strategy 2: structured edit details from Diff.meta // acp_conversion embeds SearchReplaceEditContextInformation here. if let Some(meta) = &diff.meta && let Ok(edits) = serde_json::from_value::( serde_json::Value::Object(meta.clone()), ) && !edits.details.is_empty() { let hunks = build_diff_hunks(&edits.details); let count = hunks.len().max(1); return (hunks, count); } // Strategy 3: full-text diff from old_text / new_text. // Use line numbers from meta (pre-execution preview) when available, // otherwise default to 1. let start_line = diff .meta .as_ref() .and_then(|m| m.get("new_line")) .and_then(|v| v.as_u64()) .map(|l| l as usize) .unwrap_or(1); let old = diff.old_text.as_deref().unwrap_or_default(); let hunks = diff_hunks_from_strings(old, &diff.new_text, start_line); let count = hunks.len().max(1); return (hunks, count); } } (vec![], 1) } /// Generate a unified diff patch string from diff hunks. /// /// Produces output suitable for `git apply` or clipboard sharing: /// ```text /// --- a/path/to/file /// +++ b/path/to/file /// @@ -old_start,old_count +new_start,new_count @@ /// context line /// +added line /// -removed line /// ``` pub fn diff_hunks_to_patch(path: &str, hunks: &[DiffHunk]) -> String { if hunks.is_empty() { return String::new(); } let mut out = String::new(); out.push_str(&format!("--- a/{path}\n")); out.push_str(&format!("+++ b/{path}\n")); for hunk in hunks { if hunk.is_empty() { continue; } // Compute hunk header: @@ -old_start,old_count +new_start,new_count @@ let old_start = hunk .iter() .filter(|l| l.tag != ChangeTag::Insert) .map(|l| l.lo) .next() .unwrap_or(1); let new_start = hunk .iter() .filter(|l| l.tag != ChangeTag::Delete) .map(|l| l.ln) .next() .unwrap_or(1); let old_count = hunk.iter().filter(|l| l.tag != ChangeTag::Insert).count(); let new_count = hunk.iter().filter(|l| l.tag != ChangeTag::Delete).count(); out.push_str(&format!( "@@ -{old_start},{old_count} +{new_start},{new_count} @@\n" )); for line in hunk { let prefix = match line.tag { ChangeTag::Equal => ' ', ChangeTag::Insert => '+', ChangeTag::Delete => '-', }; let text = line.text.trim_end_matches(['\r', '\n']); out.push(prefix); out.push_str(text); out.push('\n'); } } out } #[cfg(test)] mod tests { use super::*; use similar::ChangeTag; #[test] fn simple_replacement() { let details = vec![SearchReplaceEditDetail { old_string: "let x = 1;".to_string(), new_string: "let x = 2;".to_string(), old_line: 5, new_line: 5, context_before: "fn main() {\n".to_string(), context_after: "}".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; // Should have: context_before + delete + insert + context_after assert!(hunk.len() >= 3, "got {} lines", hunk.len()); // Find the delete and insert lines let deletes: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Delete).collect(); let inserts: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Insert).collect(); assert_eq!(deletes.len(), 1); assert_eq!(inserts.len(), 1); assert!(deletes[0].text.contains("let x = 1;")); assert!(inserts[0].text.contains("let x = 2;")); } #[test] fn multiple_edits_produce_multiple_hunks() { let details = vec![ SearchReplaceEditDetail { old_string: "foo".to_string(), new_string: "bar".to_string(), old_line: 1, new_line: 1, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }, SearchReplaceEditDetail { old_string: "baz".to_string(), new_string: "qux".to_string(), old_line: 10, new_line: 10, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }, ]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 2); } #[test] fn no_change_produces_no_hunks() { let details = vec![SearchReplaceEditDetail { old_string: "same".to_string(), new_string: "same".to_string(), old_line: 1, new_line: 1, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 0, "identical text should produce no hunks"); } #[test] fn context_lines_are_included() { let details = vec![SearchReplaceEditDetail { old_string: "old".to_string(), new_string: "new".to_string(), old_line: 5, new_line: 5, context_before: "line3\nline4\n".to_string(), context_after: "line6\nline7\n".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; let equal_lines: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Equal).collect(); // Should have context lines (before + after) assert!( equal_lines.len() >= 2, "expected context lines, got {}", equal_lines.len() ); } #[test] fn line_numbers_are_correct() { let details = vec![SearchReplaceEditDetail { old_string: "old".to_string(), new_string: "new".to_string(), old_line: 10, new_line: 10, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); let hunk = &hunks[0]; let delete = hunk.iter().find(|l| l.tag == ChangeTag::Delete).unwrap(); assert_eq!(delete.lo, 10); let insert = hunk.iter().find(|l| l.tag == ChangeTag::Insert).unwrap(); assert_eq!(insert.ln, 10); } #[test] fn context_before_line_numbers_precede_edit() { // Context lines should have line numbers *before* old_line, not at old_line. let details = vec![SearchReplaceEditDetail { old_string: "old".to_string(), new_string: "new".to_string(), old_line: 5, new_line: 5, context_before: "ctx1\nctx2".to_string(), context_after: String::new(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); let hunk = &hunks[0]; let ctx: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Equal).collect(); assert_eq!(ctx.len(), 2); assert_eq!(ctx[0].lo, 3); // old_line - 2 assert_eq!(ctx[1].lo, 4); // old_line - 1 } #[test] fn insert_after_no_duplicate_line_numbers() { // Simulates insert_after: old_string is empty, new_string has content. // old_line is set to the line after the anchor (where the insertion goes). let details = vec![SearchReplaceEditDetail { old_string: String::new(), new_string: "inserted line".to_string(), old_line: 5, new_line: 5, context_before: "anchor line".to_string(), context_after: "next line\n".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; // Collect all `ln` values that appear in the "new" column. let new_column: Vec = hunk .iter() .filter(|l| l.tag != ChangeTag::Delete) .map(|l| l.ln) .collect(); // No new-column line number should repeat. for i in 1..new_column.len() { assert_ne!( new_column[i - 1], new_column[i], "duplicate new-line number {} at positions {} and {}", new_column[i], i - 1, i, ); } // Context_before "anchor line" should be at ln = 4 (old_line - 1). let ctx: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Equal).collect(); assert_eq!(ctx[0].ln, 4, "context_before should be at new_line - 1"); // Insert should be at ln = 5 (new_line). let ins: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Insert).collect(); assert_eq!(ins.len(), 1); assert_eq!(ins[0].ln, 5); } #[test] fn replace_in_place_line_numbers() { // Replace one line with one line (no net addition/removal). // old_line == new_line because the line count doesn't change. let details = vec![SearchReplaceEditDetail { old_string: " let x = 1;".to_string(), new_string: " let x = 42;".to_string(), old_line: 5, new_line: 5, context_before: "fn main() {\n // setup".to_string(), context_after: " let y = x + 1;\n}\n".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; // Expected layout: // 3 3 fn main() { (context_before) // 4 4 // setup (context_before) // 5 let x = 1; (delete) // 5 let x = 42; (insert) // 6 6 let y = x + 1; (context_after) // 7 7 } (context_after) // Context before: lines 3, 4 (old_line - 2, old_line - 1) let ctx_before: Vec<_> = hunk .iter() .take_while(|l| l.tag == ChangeTag::Equal) .collect(); assert_eq!(ctx_before.len(), 2); assert_eq!(ctx_before[0].lo, 3); assert_eq!(ctx_before[0].ln, 3); assert_eq!(ctx_before[1].lo, 4); assert_eq!(ctx_before[1].ln, 4); // Delete: old line 5 let del: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Delete).collect(); assert_eq!(del.len(), 1); assert_eq!(del[0].lo, 5); assert!(del[0].text.contains("let x = 1;")); // Insert: new line 5 let ins: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Insert).collect(); assert_eq!(ins.len(), 1); assert_eq!(ins[0].ln, 5); assert!(ins[0].text.contains("let x = 42;")); // Context after: lines 6/6, 7/7 let ctx_after: Vec<_> = hunk .iter() .rev() .take_while(|l| l.tag == ChangeTag::Equal) .collect::>() .into_iter() .rev() .collect::>(); assert_eq!(ctx_after.len(), 2); assert_eq!(ctx_after[0].lo, 6); assert_eq!(ctx_after[0].ln, 6); assert_eq!(ctx_after[1].lo, 7); assert_eq!(ctx_after[1].ln, 7); // Old column should be monotonically increasing (no duplicates). let old_column: Vec = hunk .iter() .filter(|l| l.tag != ChangeTag::Insert) .map(|l| l.lo) .collect(); for i in 1..old_column.len() { assert!( old_column[i] > old_column[i - 1], "old column not monotonic: {:?}", old_column, ); } // New column should be monotonically increasing (no duplicates). let new_column: Vec = hunk .iter() .filter(|l| l.tag != ChangeTag::Delete) .map(|l| l.ln) .collect(); for i in 1..new_column.len() { assert!( new_column[i] > new_column[i - 1], "new column not monotonic: {:?}", new_column, ); } } #[test] fn diff_hunks_from_strings_simple() { let hunks = diff_hunks_from_strings("hello\nworld\n", "hello\nearth\n", 1); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; let deletes: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Delete).collect(); let inserts: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Insert).collect(); assert_eq!(deletes.len(), 1); assert_eq!(inserts.len(), 1); assert!(deletes[0].text.contains("world")); assert!(inserts[0].text.contains("earth")); } #[test] fn diff_hunks_from_strings_identical() { let hunks = diff_hunks_from_strings("same\n", "same\n", 1); assert_eq!(hunks.len(), 0); } #[test] fn diff_hunks_from_strings_empty_old() { // New file creation let hunks = diff_hunks_from_strings("", "new content\n", 1); assert_eq!(hunks.len(), 1); let inserts: Vec<_> = hunks[0] .iter() .filter(|l| l.tag == ChangeTag::Insert) .collect(); assert!(!inserts.is_empty()); } #[test] fn blank_line_insert_produces_visible_hunk() { // Simulates hashline insert_after with content: "" — both old and new are empty. let details = vec![SearchReplaceEditDetail { old_string: String::new(), new_string: String::new(), old_line: 4, new_line: 4, context_before: " let y = 2;\n let z = x + y;\n".to_string(), context_after: " if z > 2 {\n println!(\"big\");\n".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!( hunks.len(), 1, "blank line insert should produce a visible hunk" ); let inserts: Vec<_> = hunks[0] .iter() .filter(|l| l.tag == ChangeTag::Insert) .collect(); assert_eq!( inserts.len(), 1, "should have exactly one inserted blank line" ); } #[test] fn empty_file_write_produces_no_hunks() { // An empty new file (write with empty content, ACP Diff old/new both // empty, no context) must not ride the blank-line heuristic into a // fabricated +1 insertion — now prominent as the collapsed header's // diffstat. let hunks = diff_hunks_from_strings("", "", 1); assert!(hunks.is_empty(), "empty-to-empty must diff to nothing"); } // ── Overlap stitching (coalesced same-file edits) ────────────────── /// An edit detail with the ±context the real search_replace tool emits /// from its own file snapshot. fn edit_detail( old: &str, new: &str, line: usize, before: &str, after: &str, ) -> SearchReplaceEditDetail { SearchReplaceEditDetail { old_string: old.to_string(), new_string: new.to_string(), old_line: line, new_line: line, context_before: before.to_string(), context_after: after.to_string(), line_prefix: String::new(), } } fn stitch_rows(hunk: &DiffHunk) -> Vec<(ChangeTag, usize, usize, &str)> { hunk.iter() .map(|l| (l.tag, l.lo, l.ln, l.text.trim_end())) .collect() } /// Session 019f646d repro: five sequential 1:1 edits on a 5-line file, /// each hunk carrying overlapping ±3 context from its own snapshot. The /// merged block must render ONE unified hunk of five -/+ pairs — no /// repeated context, no intermediate file states, no separators. #[test] fn stitch_five_sequential_full_line_edits_into_one_hunk() { let edits = [ ( "line one", "LINE ONE", 1, "", "line two\nline three\nline four\n", ), ( "line two", "LINE TWO", 2, "LINE ONE\n", "line three\nline four\nline five\n", ), ( "line three", "LINE THREE", 3, "LINE ONE\nLINE TWO\n", "line four\nline five\n", ), ( "line four", "LINE FOUR", 4, "LINE ONE\nLINE TWO\nLINE THREE\n", "line five\n", ), ( "line five", "LINE FIVE", 5, "LINE TWO\nLINE THREE\nLINE FOUR\n", "", ), ]; let hunks: Vec = edits .into_iter() .flat_map(|(old, new, line, before, after)| { build_diff_hunks(&[edit_detail(old, new, line, before, after)]) }) .collect(); assert_eq!(hunks.len(), 5, "one overlapping hunk per edit"); let stitched = stitch_overlapping_hunks(hunks); assert_eq!(stitched.len(), 1, "overlapping hunks stitch into one"); assert_eq!( stitch_rows(&stitched[0]), vec![ (ChangeTag::Delete, 1, 1, "line one"), (ChangeTag::Insert, 2, 1, "LINE ONE"), (ChangeTag::Delete, 2, 2, "line two"), (ChangeTag::Insert, 3, 2, "LINE TWO"), (ChangeTag::Delete, 3, 3, "line three"), (ChangeTag::Insert, 4, 3, "LINE THREE"), (ChangeTag::Delete, 4, 4, "line four"), (ChangeTag::Insert, 5, 4, "LINE FOUR"), (ChangeTag::Delete, 5, 5, "line five"), (ChangeTag::Insert, 6, 5, "LINE FIVE"), ] ); } #[test] fn stitch_collapses_double_edit_to_original_and_final() { // a→b then b→c on the same line: the merged hunk shows -a +c only. let first = build_diff_hunks(&[edit_detail("a", "b", 1, "", "x\n")]); let second = build_diff_hunks(&[edit_detail("b", "c", 1, "", "x\n")]); let stitched = stitch_overlapping_hunks(vec![first[0].clone(), second[0].clone()]); assert_eq!(stitched.len(), 1); let rows: Vec<(ChangeTag, usize, &str)> = stitched[0] .iter() .map(|l| (l.tag, l.ln, l.text.trim_end())) .collect(); assert_eq!( rows, vec![ (ChangeTag::Delete, 1, "a"), (ChangeTag::Insert, 1, "c"), (ChangeTag::Equal, 2, "x"), ], "no intermediate state: the chained edit renders -original +final" ); } #[test] fn stitch_bails_to_separate_hunks_on_content_disagreement() { let mk = |text: &str, lo: usize, ln: usize, tag: ChangeTag| DiffLine { text: text.to_string(), lo, ln, tag, }; let a = vec![ mk("alpha", 1, 1, ChangeTag::Equal), mk("beta", 2, 2, ChangeTag::Delete), mk("BETA", 3, 2, ChangeTag::Insert), ]; // Overlapping `ln` range but conflicting text at ln 1: line-count // drift between snapshots — coordinates lie, so keep both hunks. let b = vec![ mk("omega", 1, 1, ChangeTag::Equal), mk("gamma", 3, 3, ChangeTag::Delete), mk("GAMMA", 4, 3, ChangeTag::Insert), ]; let stitched = stitch_overlapping_hunks(vec![a.clone(), b.clone()]); assert_eq!(stitched.len(), 2, "disagreement keeps hunks separate"); assert_eq!(stitch_rows(&stitched[0]), stitch_rows(&a)); assert_eq!(stitch_rows(&stitched[1]), stitch_rows(&b)); } #[test] fn stitch_bails_to_separate_hunks_on_insert_only_overlap() { // "beta"→"BETA" at line 2, then an insert-only edit (the insert_after // shape: empty old_string) between BETA and gamma. The insertion // grows the line count, so every later `ln` in the first hunk would // lie — the unpaired-Insert arm must keep both hunks unmodified. let a = build_diff_hunks(&[edit_detail("beta", "BETA", 2, "alpha\n", "gamma\n")]); let b = build_diff_hunks(&[edit_detail("", "inserted", 3, "BETA\n", "gamma\n")]); let (a, b) = (a[0].clone(), b[0].clone()); let stitched = stitch_overlapping_hunks(vec![a.clone(), b.clone()]); assert_eq!(stitched, vec![a, b], "insert-only overlap keeps both hunks"); } #[test] fn stitch_bails_to_separate_hunks_on_delete_run_overlap() { // Delete-only edit inside the previous hunk's coverage: the pair rule // (Delete immediately followed by its same-`ln` Insert) declines, so // both hunks survive unmodified. let a = build_diff_hunks(&[edit_detail("alpha", "ALPHA", 1, "", "beta\ngamma\n")]); let b = build_diff_hunks(&[edit_detail("beta", "", 2, "ALPHA\n", "gamma\n")]); let (a, b) = (a[0].clone(), b[0].clone()); let stitched = stitch_overlapping_hunks(vec![a.clone(), b.clone()]); assert_eq!(stitched, vec![a, b], "delete-only overlap keeps both hunks"); // Same for a multi-line D,D,I,I replacement run (line-count neutral, // but not the single-line pair shape the stitcher trusts). let base = edit_detail("alpha", "ALPHA", 1, "", "beta\ngamma\ndelta\n"); let multi = edit_detail("beta\ngamma", "BETA\nGAMMA", 2, "ALPHA\n", "delta\n"); let a = build_diff_hunks(&[base])[0].clone(); let b = build_diff_hunks(&[multi])[0].clone(); let stitched = stitch_overlapping_hunks(vec![a.clone(), b.clone()]); assert_eq!(stitched, vec![a, b], "replacement run keeps both hunks"); } #[test] fn stitch_keeps_disjoint_and_non_monotonic_hunks_separate() { let far = |line: usize| { diff_hunks_from_strings(&format!("old_{line}\n"), &format!("new_{line}\n"), line) .remove(0) }; // Disjoint hunks keep today's gap-marker rendering. assert_eq!(stitch_overlapping_hunks(vec![far(5), far(40)]).len(), 2); // Later edit above the earlier one: non-monotonic, keep separate. assert_eq!(stitch_overlapping_hunks(vec![far(20), far(4)]).len(), 2); } #[test] fn context_lines_appear_in_hunks() { // Simulates hashline replace with ±3 context from to_search_replace. let details = vec![SearchReplaceEditDetail { old_string: " let x = 1;".to_string(), new_string: " let x = 42;".to_string(), old_line: 2, new_line: 2, context_before: "fn main() {\n".to_string(), context_after: " let y = 2;\n let z = x + y;\n".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; let equal_lines: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Equal).collect(); assert!( equal_lines.len() >= 2, "should have context lines, got {}", equal_lines.len() ); assert!( equal_lines.iter().any(|l| l.text.contains("fn main")), "context_before should appear in hunk" ); assert!( equal_lines.iter().any(|l| l.text.contains("let y = 2")), "context_after should appear in hunk" ); } #[test] fn extract_edit_hunks_from_raw_output() { use agent_client_protocol as acp; use kigi_tools::types::output::{ SearchReplaceEditContextInformation, SearchReplaceEditsApplied, SearchReplaceOutput, ToolOutput, }; use std::sync::Arc; let edits_applied = SearchReplaceEditsApplied { old_string: "let x = 1;".to_string(), new_string: "let x = 2;".to_string(), tool_output_for_prompt: String::new(), tool_output_for_prompt_concise: None, absolute_path: "/tmp/test.rs".into(), edits: SearchReplaceEditContextInformation { details: vec![SearchReplaceEditDetail { old_string: "let x = 1;".to_string(), new_string: "let x = 2;".to_string(), old_line: 5, new_line: 5, context_before: "fn main() {".to_string(), context_after: "}".to_string(), line_prefix: String::new(), }], }, patch: None, unicode_normalized: false, }; // Wrap in ToolOutput::SearchReplace — matches production rawOutput format let raw_output = serde_json::to_value(ToolOutput::SearchReplace( SearchReplaceOutput::EditsApplied(edits_applied), )) .unwrap(); let tc = acp::ToolCall::new( acp::ToolCallId::new(Arc::from("tc1")), "Edit test.rs".to_string(), ) .kind(acp::ToolKind::Edit) .status(acp::ToolCallStatus::Completed) .content(vec![]) .raw_output(Some(raw_output)) .locations(vec![]); let (hunks, count) = extract_edit_hunks(&tc); assert_eq!(hunks.len(), 1, "should have 1 hunk from raw_output"); assert_eq!(count, 1); // Verify the hunk has correct content let deletes: Vec<_> = hunks[0] .iter() .filter(|l| l.tag == ChangeTag::Delete) .collect(); assert_eq!(deletes.len(), 1); assert!(deletes[0].text.contains("let x = 1;")); } #[test] fn extract_edit_hunks_fallback_to_content_diff() { use agent_client_protocol as acp; use std::sync::Arc; let tc = acp::ToolCall::new( acp::ToolCallId::new(Arc::from("tc1")), "Edit test.rs".to_string(), ) .kind(acp::ToolKind::Edit) .status(acp::ToolCallStatus::Completed) .content(vec![acp::ToolCallContent::Diff( acp::Diff::new("test.rs", "hello\nearth\n".to_string()) .old_text(Some("hello\nworld\n".to_string())), )]) .locations(vec![]); let (hunks, count) = extract_edit_hunks(&tc); assert_eq!(hunks.len(), 1, "should have 1 hunk from content diff"); assert_eq!(count, 1); } #[test] fn extract_edit_hunks_empty_when_no_data() { use agent_client_protocol as acp; use std::sync::Arc; let tc = acp::ToolCall::new( acp::ToolCallId::new(Arc::from("tc1")), "Edit test.rs".to_string(), ) .kind(acp::ToolKind::Edit) .status(acp::ToolCallStatus::Completed) .content(vec![]) .locations(vec![]); let (hunks, count) = extract_edit_hunks(&tc); assert!(hunks.is_empty()); assert_eq!(count, 1); } #[test] fn extract_edit_hunks_from_diff_meta_structured() { // Strategy 2: structured edit details from Diff.meta // (acp_conversion embeds SearchReplaceEditContextInformation). use agent_client_protocol as acp; use kigi_tools::types::output::SearchReplaceEditContextInformation; use std::sync::Arc; let edits = SearchReplaceEditContextInformation { details: vec![SearchReplaceEditDetail { old_string: "let x = 1;".to_string(), new_string: "let x = 2;".to_string(), old_line: 42, new_line: 42, context_before: "fn main() {".to_string(), context_after: "}".to_string(), line_prefix: String::new(), }], }; // No raw_output — Strategy 1 skipped let tc = acp::ToolCall::new( acp::ToolCallId::new(Arc::from("tc1")), "Edit test.rs".to_string(), ) .kind(acp::ToolKind::Edit) .status(acp::ToolCallStatus::Completed) .content(vec![acp::ToolCallContent::Diff( acp::Diff::new("test.rs", "let x = 2;".to_string()) .old_text(Some("let x = 1;".to_string())) .meta( serde_json::to_value(&edits) .ok() .and_then(|v| v.as_object().cloned()), ), )]); let (hunks, count) = extract_edit_hunks(&tc); assert_eq!(hunks.len(), 1); assert_eq!(count, 1); // Line numbers should be absolute (42), not relative (1). let del = hunks[0] .iter() .find(|l| l.tag == ChangeTag::Delete) .unwrap(); assert_eq!(del.lo, 42, "old_line should be absolute"); let ins = hunks[0] .iter() .find(|l| l.tag == ChangeTag::Insert) .unwrap(); assert_eq!(ins.ln, 42, "new_line should be absolute"); } #[test] fn extract_edit_hunks_from_diff_meta_start_line() { // Strategy 3: pre-execution preview with simple {old_line, new_line} meta. use agent_client_protocol as acp; use std::sync::Arc; let tc = acp::ToolCall::new( acp::ToolCallId::new(Arc::from("tc1")), "Edit test.rs".to_string(), ) .kind(acp::ToolKind::Edit) .status(acp::ToolCallStatus::InProgress) .content(vec![acp::ToolCallContent::Diff( acp::Diff::new("test.rs", "new_val".to_string()) .old_text(Some("old_val".to_string())) .meta( serde_json::json!({"old_line": 50, "new_line": 50}) .as_object() .cloned(), ), )]) .locations(vec![]); let (hunks, count) = extract_edit_hunks(&tc); assert_eq!(hunks.len(), 1); assert_eq!(count, 1); // Line numbers should use start_line=50 from meta, not 1. let del = hunks[0] .iter() .find(|l| l.tag == ChangeTag::Delete) .unwrap(); assert_eq!(del.lo, 50, "old_line should come from meta"); let ins = hunks[0] .iter() .find(|l| l.tag == ChangeTag::Insert) .unwrap(); assert_eq!(ins.ln, 50, "new_line should come from meta"); } #[test] fn context_before_trailing_newline_no_phantom_line() { // When context_before ends with '\n', with_nl should not double it, // otherwise a phantom empty context line appears in the rendered diff. let details = vec![SearchReplaceEditDetail { old_string: String::new(), new_string: " new_field: None,".to_string(), old_line: 5, new_line: 5, context_before: " field_a: None,\n field_b: Vec::new(),\n".to_string(), context_after: "}".to_string(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; // Context before should have exactly 2 lines, not 3 (no phantom blank). let ctx: Vec<_> = hunk .iter() .take_while(|l| l.tag == ChangeTag::Equal) .collect(); assert_eq!( ctx.len(), 2, "trailing newline in context_before should not create a phantom blank line, got: {:?}", ctx.iter().map(|l| &l.text).collect::>() ); // Verify no blank-only equal lines exist before the insert. let blank_ctx: Vec<_> = hunk .iter() .filter(|l| l.tag == ChangeTag::Equal && l.text.trim().is_empty()) .collect(); assert!( blank_ctx.is_empty(), "should have no blank context lines, got {} phantom lines", blank_ctx.len() ); } #[test] fn line_prefix_prepended_to_changed_lines() { // Simulates a mid-line match: the file line is " .filter(|t| old)" // but old_string is just ".filter(|t| old)" — the 12-space indent is the prefix. let details = vec![SearchReplaceEditDetail { old_string: ".filter(|t| old)".to_string(), new_string: ".filter(|t| new)".to_string(), old_line: 5, new_line: 5, context_before: " .values()\n".to_string(), context_after: " .count()\n".to_string(), line_prefix: " ".to_string(), // 12 spaces }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; let del: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Delete).collect(); let ins: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Insert).collect(); assert_eq!(del.len(), 1); assert_eq!(ins.len(), 1); // Both changed lines must start with the 12-space prefix. assert!( del[0].text.starts_with(" .filter"), "delete line should have leading indent, got: {:?}", del[0].text, ); assert!( ins[0].text.starts_with(" .filter"), "insert line should have leading indent, got: {:?}", ins[0].text, ); // Context lines already have their own indent (from the file). let ctx: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Equal).collect(); assert!(ctx.iter().all(|l| l.text.starts_with(" ."))); } #[test] fn line_prefix_only_on_first_line_of_multiline_match() { // Multi-line old_string: only the first line should get the prefix. let details = vec![SearchReplaceEditDetail { old_string: "call_a()\n call_b()\n".to_string(), new_string: "call_x()\n call_y()\n".to_string(), old_line: 3, new_line: 3, context_before: "fn example() {\n".to_string(), context_after: "}\n".to_string(), line_prefix: " ".to_string(), // 4 spaces }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let hunk = &hunks[0]; let dels: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Delete).collect(); let inss: Vec<_> = hunk.iter().filter(|l| l.tag == ChangeTag::Insert).collect(); assert_eq!(dels.len(), 2); assert_eq!(inss.len(), 2); // First delete/insert line: prefix applied. assert!( dels[0].text.starts_with(" call_a"), "first delete should have prefix, got: {:?}", dels[0].text, ); assert!( inss[0].text.starts_with(" call_x"), "first insert should have prefix, got: {:?}", inss[0].text, ); // Second delete/insert line: NO extra prefix (already a full file line). assert!( dels[1].text.starts_with(" call_b"), "second delete should keep original indent, got: {:?}", dels[1].text, ); assert!( inss[1].text.starts_with(" call_y"), "second insert should keep original indent, got: {:?}", inss[1].text, ); } #[test] fn empty_line_prefix_changes_nothing() { // When line_prefix is empty, behavior is unchanged from before. let details = vec![SearchReplaceEditDetail { old_string: "old_val".to_string(), new_string: "new_val".to_string(), old_line: 1, new_line: 1, context_before: String::new(), context_after: String::new(), line_prefix: String::new(), }]; let hunks = build_diff_hunks(&details); assert_eq!(hunks.len(), 1); let del = hunks[0] .iter() .find(|l| l.tag == ChangeTag::Delete) .unwrap(); let ins = hunks[0] .iter() .find(|l| l.tag == ChangeTag::Insert) .unwrap(); assert!(del.text.starts_with("old_val")); assert!(ins.text.starts_with("new_val")); } }