vtcode_core/tools/file_ops/
diff_preview.rs1use 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
11pub 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
20pub const SUPPRESSED_PREVIEW_REASON: &str = "too_many_changes";
25
26pub 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
41pub 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
79pub 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
94pub 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
130pub 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 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
178pub 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 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}