Skip to main content

vtcode_core/tools/file_ops/
diff_preview.rs

1//! Diff preview utilities for file operations.
2
3use crate::config::constants::diff;
4use serde_json::{Value, json};
5use std::time::Instant;
6use vtcode_diff::{
7    DiffDisplayKind, DiffDisplayLine, DiffDocument, DiffHunk, DiffOptions, count_diff_changes,
8    display_lines_from_unified_diff, format_unified_hunks,
9};
10
11/// Create a diff preview response when content exceeds the size limit.
12pub fn diff_preview_size_skip() -> Value {
13    json!({
14        "skipped": true,
15        "reason": "content_exceeds_preview_limit",
16        "max_bytes": diff::MAX_PREVIEW_BYTES
17    })
18}
19
20/// Stable machine-readable reason for suppressed diff previews.
21///
22/// Kept stable for `ThreadEvent`/API consumers. Renderers must show
23/// [`diff_preview_user_message`] instead of this code.
24pub const SUPPRESSED_PREVIEW_REASON: &str = "too_many_changes";
25
26/// Create a diff preview response when inline diffs are suppressed due to too many changes.
27pub fn diff_preview_suppressed(additions: usize, deletions: usize, line_count: usize) -> Value {
28    json!({
29        "skipped": true,
30        "suppressed": true,
31        "reason": SUPPRESSED_PREVIEW_REASON,
32        "message": diff::SUPPRESSION_MESSAGE,
33        "summary": {
34            "additions": additions,
35            "deletions": deletions,
36            "total_lines": line_count
37        }
38    })
39}
40
41/// User-facing one-line summary for a skipped/suppressed diff entry.
42///
43/// Prefers the producer-supplied `message`, maps known `reason` codes to
44/// friendly text, and humanizes unknown snake_case codes. The stable `reason`
45/// code itself is never surfaced for known cases.
46pub fn diff_preview_user_message(preview: &Value) -> String {
47    if let Some(message) = preview
48        .get("message")
49        .and_then(Value::as_str)
50        .map(str::trim)
51        .filter(|s| !s.is_empty())
52    {
53        return message.to_owned();
54    }
55    let reason = preview.get("reason").and_then(Value::as_str).map(str::trim).unwrap_or_default();
56    match reason {
57        "too_many_changes" => {
58            let additions = preview
59                .get("additions")
60                .and_then(Value::as_u64)
61                .or_else(|| preview.get("summary").and_then(|s| s.get("additions")).and_then(Value::as_u64));
62            let deletions = preview
63                .get("deletions")
64                .and_then(Value::as_u64)
65                .or_else(|| preview.get("summary").and_then(|s| s.get("deletions")).and_then(Value::as_u64));
66            match (additions, deletions) {
67                (Some(a), Some(d)) => {
68                    format!("Large change — preview suppressed (+{a} -{d}); use `git diff` for the full view")
69                }
70                _ => "Large change — preview suppressed; use `git diff` for the full view".to_owned(),
71            }
72        }
73        "content_exceeds_preview_limit" => "Preview skipped — file exceeds the preview size limit".to_owned(),
74        "" => "preview skipped".to_owned(),
75        other => other.replace('_', " "),
76    }
77}
78
79/// Create a diff preview response when an error prevents diff generation.
80pub fn diff_preview_error_skip(reason: &str, detail: Option<&str>) -> Value {
81    match detail {
82        Some(value) => json!({
83            "skipped": true,
84            "reason": reason,
85            "detail": value
86        }),
87        None => json!({
88            "skipped": true,
89            "reason": reason
90        }),
91    }
92}
93
94/// Return the canonical bounded diff entries from a file-operation response.
95///
96/// `diff[]` is the shared response contract for multi-file operations. The
97/// legacy `diff_preview` object is normalized into a one-entry list so older
98/// write/create responses render and persist through the same path.
99pub fn canonical_diff_previews(output: &Value) -> Vec<Value> {
100    if let Some(diffs) = output.get("diff").and_then(Value::as_array) {
101        return diffs.clone();
102    }
103
104    let Some(preview) = output.get("diff_preview") else {
105        return Vec::new();
106    };
107
108    let mut entry = preview.clone();
109    if let Some(fields) = entry.as_object_mut() {
110        if !fields.contains_key("path")
111            && let Some(path) = output.get("path").and_then(Value::as_str)
112        {
113            fields.insert("path".to_string(), Value::String(path.to_string()));
114        }
115        if !fields.contains_key("operation") {
116            let operation = if output.get("created").and_then(Value::as_bool) == Some(true)
117                || output.get("file_existed").and_then(Value::as_bool) == Some(false)
118            {
119                "created"
120            } else {
121                "updated"
122            };
123            fields.insert("operation".to_string(), Value::String(operation.to_string()));
124        }
125    }
126
127    vec![entry]
128}
129
130/// Determine whether a canonical diff contract represents an effective
131/// mutation. `None` means the response is not a file-operation response and
132/// callers should use the tool's normal success semantics.
133pub fn diff_output_has_effective_change(output: &Value) -> Option<bool> {
134    if output.get("skipped").and_then(Value::as_bool) == Some(true)
135        || output.get("conflict").and_then(Value::as_bool) == Some(true)
136        || output.get("success").and_then(Value::as_bool) == Some(false)
137    {
138        return Some(false);
139    }
140
141    if output.get("diff").is_none() && output.get("diff_preview").is_none() {
142        return None;
143    }
144
145    Some(canonical_diff_previews(output).iter().any(diff_preview_has_effective_change))
146}
147
148fn diff_preview_has_effective_change(preview: &Value) -> bool {
149    if preview.get("is_empty").and_then(Value::as_bool) == Some(true) {
150        return matches!(preview.get("operation").and_then(Value::as_str), Some("created" | "deleted"));
151    }
152
153    if preview
154        .get("content")
155        .and_then(Value::as_str)
156        .is_some_and(|content| !content.is_empty())
157    {
158        return true;
159    }
160
161    // A skipped preview still represents a completed mutation when the
162    // operation itself succeeded; only an explicit `is_empty` preview is a
163    // no-op. This covers bounded/suppressed previews without over-counting
164    // writes that were skipped or conflicted at the response level.
165    preview.get("skipped").and_then(Value::as_bool) == Some(true)
166        || preview
167            .get("summary")
168            .and_then(|summary| summary.get("additions"))
169            .and_then(Value::as_u64)
170            .is_some_and(|additions| additions > 0)
171        || preview
172            .get("summary")
173            .and_then(|summary| summary.get("deletions"))
174            .and_then(Value::as_u64)
175            .is_some_and(|deletions| deletions > 0)
176}
177
178/// Build a unified diff preview between before and after content.
179pub fn build_diff_preview(path: &str, before: Option<&str>, after: &str) -> Value {
180    let started = Instant::now();
181    let previous = before.unwrap_or("");
182    let old_label = format!("a/{path}");
183    let new_label = format!("b/{path}");
184
185    let options = DiffOptions {
186        context_lines: diff::CONTEXT_RADIUS,
187        old_label: Some(old_label.as_str()),
188        new_label: Some(new_label.as_str()),
189        missing_newline_hint: true,
190        ..DiffOptions::default()
191    };
192    let document = DiffDocument::between(previous, after, options.clone());
193    // Tool responses carry a plain unified diff. The terminal/UI renderers
194    // apply colors, gutters, and syntax highlighting after parsing the diff;
195    // embedding ANSI here would hide hunk markers and line prefixes from that
196    // parser and make apply_patch previews fall back to raw text.
197    let formatted = format_unified_hunks(&document.hunks, &options);
198
199    if formatted.trim().is_empty() {
200        tracing::debug!(
201            target: "vtcode.tools.diff",
202            path,
203            before_bytes = previous.len(),
204            after_bytes = after.len(),
205            additions = 0,
206            deletions = 0,
207            line_count = 0,
208            truncated = false,
209            suppressed = false,
210            elapsed_ms = started.elapsed().as_millis(),
211            "diff preview generated"
212        );
213
214        return json!({
215            "content": "",
216            "truncated": false,
217            "omitted_line_count": 0,
218            "skipped": false,
219            "is_empty": true,
220            "additions": 0,
221            "deletions": 0
222        });
223    }
224
225    let line_count = formatted.lines().count();
226    let counts = count_diff_changes(&document.hunks);
227    let additions = counts.additions;
228    let deletions = counts.deletions;
229    if line_count > diff::MAX_PREVIEW_LINES {
230        let lines: Vec<&str> = formatted.lines().collect();
231        let head_count = diff::HEAD_LINE_COUNT.min(lines.len());
232        let base_tail_count = diff::TAIL_LINE_COUNT.min(lines.len().saturating_sub(head_count));
233        let full_display = (document.hunks.len() > 1).then(|| display_lines_from_unified_diff(&formatted));
234        let mut tail_count = base_tail_count;
235        let mut tail_hunk_header = full_display.as_ref().and_then(|display| {
236            bounded_tail_hunk_header(display, &document.hunks, lines.len().saturating_sub(tail_count))
237        });
238        if tail_hunk_header.is_some() && tail_count > 0 {
239            tail_count = tail_count.saturating_sub(1);
240            tail_hunk_header = full_display.as_ref().and_then(|display| {
241                bounded_tail_hunk_header(display, &document.hunks, lines.len().saturating_sub(tail_count))
242            });
243            if tail_hunk_header.is_none() {
244                tail_count = base_tail_count;
245            }
246        }
247        let omitted = lines.len().saturating_sub(head_count + tail_count);
248
249        let diff_output = if omitted > 0 {
250            let mut result = lines[..head_count].join("\n");
251            result.push_str(&format!("\n... {omitted} lines omitted ...\n"));
252            if let Some(header) = tail_hunk_header {
253                result.push_str(&header);
254                result.push('\n');
255            }
256            result.push_str(&lines[lines.len().saturating_sub(tail_count)..].join("\n"));
257            result
258        } else {
259            lines.join("\n")
260        };
261
262        let elapsed = started.elapsed().as_millis();
263
264        tracing::debug!(
265            target: "vtcode.tools.diff",
266            path,
267            before_bytes = previous.len(),
268            after_bytes = after.len(),
269            additions,
270            deletions,
271            line_count,
272            omitted_lines = omitted,
273            truncated = true,
274            suppressed = false,
275            elapsed_ms = elapsed,
276            "diff preview generated"
277        );
278
279        json!({
280            "content": diff_output,
281            "truncated": true,
282            "omitted_line_count": omitted,
283            "skipped": false,
284            "additions": additions,
285            "deletions": deletions
286        })
287    } else {
288        let elapsed = started.elapsed().as_millis();
289
290        tracing::debug!(
291            target: "vtcode.tools.diff",
292            path,
293            before_bytes = previous.len(),
294            after_bytes = after.len(),
295            additions,
296            deletions,
297            line_count,
298            truncated = false,
299            suppressed = false,
300            elapsed_ms = elapsed,
301            "diff preview generated"
302        );
303
304        json!({
305            "content": formatted,
306            "truncated": false,
307            "omitted_line_count": 0,
308            "skipped": false,
309            "additions": additions,
310            "deletions": deletions
311        })
312    }
313}
314
315fn bounded_tail_hunk_header(display: &[DiffDisplayLine], hunks: &[DiffHunk], tail_start: usize) -> Option<String> {
316    let tail = display.get(tail_start..)?;
317    let first_body_offset = tail.iter().position(DiffDisplayLine::is_diff)?;
318    if tail[..first_body_offset]
319        .iter()
320        .any(|line| line.kind == DiffDisplayKind::HunkHeader)
321    {
322        return None;
323    }
324
325    let first_body_index = tail_start + first_body_offset;
326    let hunk_header_index = display[..first_body_index]
327        .iter()
328        .rposition(|line| line.kind == DiffDisplayKind::HunkHeader)?;
329    let hunk_index = display[..first_body_index]
330        .iter()
331        .filter(|line| line.kind == DiffDisplayKind::HunkHeader)
332        .count()
333        .checked_sub(1)?;
334    let hunk = hunks.get(hunk_index)?;
335    let prefix_line_count = display[hunk_header_index + 1..first_body_index]
336        .iter()
337        .filter(|line| line.is_diff())
338        .count();
339    let prefix = hunk.lines.get(..prefix_line_count)?;
340    let mut old_start = hunk.old_start;
341    let mut new_start = hunk.new_start;
342    for line in prefix {
343        if line.kind != vtcode_diff::DiffLineKind::Addition {
344            old_start = old_start.saturating_add(1);
345        }
346        if line.kind != vtcode_diff::DiffLineKind::Deletion {
347            new_start = new_start.saturating_add(1);
348        }
349    }
350
351    let tail_lines = tail[first_body_offset..]
352        .iter()
353        .take_while(|line| line.kind != DiffDisplayKind::HunkHeader)
354        .filter(|line| line.is_diff());
355    let mut old_lines = 0usize;
356    let mut new_lines = 0usize;
357    for line in tail_lines {
358        if line.kind != DiffDisplayKind::Addition {
359            old_lines = old_lines.saturating_add(1);
360        }
361        if line.kind != DiffDisplayKind::Deletion {
362            new_lines = new_lines.saturating_add(1);
363        }
364    }
365    (old_lines > 0 || new_lines > 0).then(|| format!("@@ -{old_start},{old_lines} +{new_start},{new_lines} @@"))
366}
367
368#[cfg(test)]
369mod tests {
370    use serde_json::json;
371    use vtcode_commons::ansi::strip_ansi;
372
373    use super::*;
374
375    #[test]
376    fn canonical_diff_previews_normalize_legacy_write_output() {
377        let previews = canonical_diff_previews(&json!({
378            "path": "README.md",
379            "file_existed": true,
380            "diff_preview": {"content": "diff", "skipped": false}
381        }));
382
383        assert_eq!(previews.len(), 1);
384        assert_eq!(previews[0]["path"], "README.md");
385        assert_eq!(previews[0]["operation"], "updated");
386    }
387
388    #[test]
389    fn effective_change_distinguishes_noop_skipped_and_empty_file_operations() {
390        assert_eq!(
391            diff_output_has_effective_change(&json!({
392                "success": true,
393                "diff": [{"operation": "updated", "is_empty": true}]
394            })),
395            Some(false)
396        );
397        assert_eq!(
398            diff_output_has_effective_change(&json!({"success": true, "skipped": true, "diff": []})),
399            Some(false)
400        );
401        assert_eq!(
402            diff_output_has_effective_change(&json!({
403                "success": true,
404                "diff": [{"operation": "created", "is_empty": true}]
405            })),
406            Some(true)
407        );
408    }
409
410    #[test]
411    fn suppressed_preview_user_message_prefers_message_and_maps_reason_codes() {
412        let suppressed = diff_preview_suppressed(12, 3, 40);
413        assert_eq!(suppressed["reason"], SUPPRESSED_PREVIEW_REASON);
414        let rendered = diff_preview_user_message(&suppressed);
415        assert_eq!(rendered, diff::SUPPRESSION_MESSAGE);
416        assert!(!rendered.contains("too_many_changes"));
417
418        let reason_only = json!({
419            "skipped": true,
420            "reason": SUPPRESSED_PREVIEW_REASON,
421            "summary": {"additions": 12, "deletions": 3}
422        });
423        let fallback = diff_preview_user_message(&reason_only);
424        assert!(fallback.contains("+12 -3"), "fallback should keep counts: {fallback}");
425        assert!(!fallback.contains("too_many_changes"));
426
427        let oversized = json!({"skipped": true, "reason": "content_exceeds_preview_limit"});
428        assert_eq!(diff_preview_user_message(&oversized), "Preview skipped — file exceeds the preview size limit");
429
430        let unknown = json!({"skipped": true, "reason": "custom_retry_later"});
431        assert_eq!(diff_preview_user_message(&unknown), "custom retry later");
432    }
433
434    #[test]
435    fn build_diff_preview_keeps_serialized_content_plain_for_ui_parsing() {
436        let preview = build_diff_preview("README.md", Some("before\n"), "after\n");
437        let content = preview
438            .get("content")
439            .and_then(Value::as_str)
440            .expect("changed preview should contain diff content");
441
442        assert_eq!(content, strip_ansi(content));
443        assert!(content.contains("-before"));
444        assert!(content.contains("+after"));
445    }
446
447    #[test]
448    fn large_change_uses_bounded_head_tail_instead_of_suppression() {
449        let before = (0..=diff::MAX_SINGLE_FILE_CHANGES)
450            .map(|index| format!("old-{index}\n"))
451            .collect::<String>();
452        let after = (0..=diff::MAX_SINGLE_FILE_CHANGES)
453            .map(|index| format!("new-{index}\n"))
454            .collect::<String>();
455
456        let preview = build_diff_preview("large.txt", Some(&before), &after);
457
458        assert_eq!(preview["skipped"], false);
459        assert_eq!(preview["truncated"], true);
460        assert!(preview["omitted_line_count"].as_u64().is_some_and(|count| count > 0));
461        let content = preview["content"].as_str().expect("bounded diff content");
462        assert!(content.contains("lines omitted"));
463        assert!(content.contains("old-0"));
464        assert!(content.contains(&format!("new-{}", diff::MAX_SINGLE_FILE_CHANGES)));
465
466        let display_lines = display_lines_from_unified_diff(content);
467        let tail = display_lines
468            .iter()
469            .find(|line| line.text.starts_with("new-200"))
470            .expect("tail line");
471        assert_eq!(tail.new_line, Some(201));
472    }
473
474    #[test]
475    fn large_multi_hunk_preview_keeps_tail_hunk_numbers() {
476        let before = (0..600).map(|index| format!("old-{index}\n")).collect::<String>();
477        let mut after_lines = (0..600).map(|index| format!("old-{index}\n")).collect::<Vec<_>>();
478        for (index, line) in after_lines.iter_mut().enumerate().take(220).skip(100) {
479            *line = format!("new-{index}\n");
480        }
481        for (index, line) in after_lines.iter_mut().enumerate().take(520).skip(400) {
482            *line = format!("new-{index}\n");
483        }
484
485        let preview = build_diff_preview("large-multi.txt", Some(&before), &after_lines.concat());
486        let content = preview["content"].as_str().expect("bounded diff content");
487        let display_lines = display_lines_from_unified_diff(content);
488        let tail = display_lines
489            .iter()
490            .find(|line| line.text.starts_with("new-519"))
491            .expect("tail hunk line");
492        assert_eq!(tail.new_line, Some(520));
493    }
494}