1371 lines
50 KiB
Rust
1371 lines
50 KiB
Rust
|
|
use similar::{ChangeTag, TextDiff};
|
||
|
|
use xai_grok_tools::types::output::SearchReplaceEditDetail;
|
||
|
|
|
||
|
|
#[derive(Debug, Clone, PartialEq)]
|
||
|
|
pub struct DiffLine {
|
||
|
|
pub text: String,
|
||
|
|
pub lo: usize,
|
||
|
|
pub ln: usize,
|
||
|
|
pub tag: ChangeTag,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub type DiffHunk = Vec<DiffLine>;
|
||
|
|
|
||
|
|
pub fn build_diff_hunks(details: &[SearchReplaceEditDetail]) -> Vec<DiffHunk> {
|
||
|
|
const MAX_CONTEXT: usize = 3;
|
||
|
|
let mut hunks: Vec<DiffHunk> = Vec::new();
|
||
|
|
|
||
|
|
for edit in details {
|
||
|
|
let mut diff_lines: DiffHunk = Vec::new();
|
||
|
|
let before_lines: Vec<String> = 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<DiffHunk> {
|
||
|
|
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<DiffHunk>) -> Vec<DiffHunk> {
|
||
|
|
let mut out: Vec<DiffHunk> = 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<usize> {
|
||
|
|
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<DiffHunk> {
|
||
|
|
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<DiffHunk>, usize) {
|
||
|
|
use xai_grok_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::<ToolOutput>(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::<SearchReplaceEditContextInformation>(
|
||
|
|
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<usize> = 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::<Vec<_>>()
|
||
|
|
.into_iter()
|
||
|
|
.rev()
|
||
|
|
.collect::<Vec<_>>();
|
||
|
|
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<usize> = 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<usize> = 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<DiffHunk> = 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 std::sync::Arc;
|
||
|
|
use xai_grok_tools::types::output::{
|
||
|
|
SearchReplaceEditContextInformation, SearchReplaceEditsApplied, SearchReplaceOutput,
|
||
|
|
ToolOutput,
|
||
|
|
};
|
||
|
|
|
||
|
|
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 std::sync::Arc;
|
||
|
|
use xai_grok_tools::types::output::SearchReplaceEditContextInformation;
|
||
|
|
|
||
|
|
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::<Vec<_>>()
|
||
|
|
);
|
||
|
|
|
||
|
|
// 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"));
|
||
|
|
}
|
||
|
|
}
|