1#![allow(
2 clippy::let_underscore_must_use,
3 reason = "Intentional compatibility, platform, or test-only suppression."
4)]
5
6use crate::config::constants::diff as diff_constants;
7use crate::ui::git_config::GitColorConfig;
8use crate::utils::style_helpers::style_from_color_name;
9use anstyle::{Reset, Style};
10use std::fmt::Write as _;
11use std::path::Path;
12use vtcode_commons::styling::DiffColorPalette;
13use vtcode_diff::{
14 DiffDocument, DiffLineKind as SharedDiffLineKind, DiffOptions as SharedDiffOptions, format_unified_diff,
15};
16
17struct GitDiffPalette {
18 bullet: Style,
19 label: Style,
20 path: Style,
21 stat_added: Style,
22 stat_removed: Style,
23 line_added: Style,
24 line_removed: Style,
25 line_context: Style,
26 line_header: Style,
27 line_number: Style,
28}
29
30fn strip_diff_background(style: Style) -> Style {
31 style.bg_color(None)
32}
33
34impl GitDiffPalette {
35 fn new(use_colors: bool) -> Self {
36 let palette = DiffColorPalette::default();
38 let added = palette.added_style();
39 let removed = palette.removed_style();
40 let header = palette.header_style();
41
42 Self {
43 bullet: if use_colors {
44 style_from_color_name("cyan")
45 } else {
46 Style::new()
47 }, label: Style::new(), path: Style::new(), stat_added: strip_diff_background(added),
51 stat_removed: strip_diff_background(removed),
52 line_added: strip_diff_background(added),
53 line_removed: strip_diff_background(removed),
54 line_context: if use_colors {
55 Style::new().dimmed()
56 } else {
57 Style::new()
58 },
59 line_header: strip_diff_background(header),
60 line_number: if use_colors {
61 style_from_color_name("cyan")
62 } else {
63 Style::new()
64 }, }
66 }
67
68 fn from_git_config(config: &GitColorConfig, use_colors: bool) -> Self {
70 if !use_colors {
71 return Self::new(false);
72 }
73
74 let palette = DiffColorPalette::default();
76 let added = palette.added_style();
77 let removed = palette.removed_style();
78 let header = palette.header_style();
79
80 Self {
81 bullet: style_from_color_name("cyan"),
82 label: Style::new(),
83 path: Style::new(),
84 stat_added: strip_diff_background(added),
85 stat_removed: strip_diff_background(removed),
86 line_added: strip_diff_background(added),
87 line_removed: strip_diff_background(removed),
88 line_context: strip_diff_background(config.diff_context),
89 line_header: strip_diff_background(header),
90 line_number: style_from_color_name("cyan"),
91 }
92 }
93}
94
95#[derive(Debug, Clone)]
97pub struct DiffLine {
98 pub line_type: DiffLineType,
100 pub content: String,
102 pub line_number_old: Option<u32>,
104 pub line_number_new: Option<u32>,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum DiffLineType {
111 Added,
113 Removed,
115 Context,
117 Header,
119}
120
121#[derive(Debug)]
123pub struct FileDiff {
124 pub file_path: String,
126 pub lines: Vec<DiffLine>,
128 pub stats: DiffStats,
130}
131
132#[derive(Debug, Clone)]
134pub struct DiffStats {
135 pub additions: usize,
137 pub deletions: usize,
139 pub changes: usize,
141}
142
143#[derive(Debug, Clone)]
145pub struct DiffSuppressionCheck {
146 pub should_suppress: bool,
148 pub reason: Option<String>,
150 pub file_count: usize,
152 pub total_lines: usize,
154 pub total_additions: usize,
156 pub total_deletions: usize,
158 pub file_stats: Vec<FileChangeStats>,
160}
161
162#[derive(Debug, Clone)]
164pub struct FileChangeStats {
165 pub path: String,
167 pub additions: usize,
169 pub deletions: usize,
171}
172
173pub struct SuppressionResult {
175 pub check: DiffSuppressionCheck,
177}
178
179impl SuppressionResult {
180 fn suppressed(check: DiffSuppressionCheck) -> Self {
181 Self { check }
182 }
183}
184
185impl DiffSuppressionCheck {
186 pub fn no_suppression(
188 file_count: usize,
189 total_lines: usize,
190 additions: usize,
191 deletions: usize,
192 file_stats: Vec<FileChangeStats>,
193 ) -> Self {
194 Self {
195 should_suppress: false,
196 reason: None,
197 file_count,
198 total_lines,
199 total_additions: additions,
200 total_deletions: deletions,
201 file_stats,
202 }
203 }
204
205 pub fn suppressed(
207 reason: String,
208 file_count: usize,
209 total_lines: usize,
210 additions: usize,
211 deletions: usize,
212 file_stats: Vec<FileChangeStats>,
213 ) -> Self {
214 Self {
215 should_suppress: true,
216 reason: Some(reason),
217 file_count,
218 total_lines,
219 total_additions: additions,
220 total_deletions: deletions,
221 file_stats,
222 }
223 }
224}
225
226pub struct DiffRenderer {
228 show_line_numbers: bool,
229 context_lines: usize,
230 use_colors: bool,
231 cached_styles: CachedStyles,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct DiffRendererOptions {
242 pub show_line_numbers: bool,
244 pub context_lines: usize,
246 pub use_colors: bool,
248}
249
250impl Default for DiffRendererOptions {
251 fn default() -> Self {
252 Self {
253 show_line_numbers: true,
254 context_lines: 3,
255 use_colors: false,
256 }
257 }
258}
259
260struct CachedStyles {
262 bullet: String,
263 label: String,
264 path: String,
265 stat_added: String,
266 stat_removed: String,
267 line_added: String,
268 line_removed: String,
269 line_context: String,
270 line_header: String,
271 line_number: String,
272 reset: String,
273}
274
275impl CachedStyles {
276 fn new(palette: &GitDiffPalette, use_colors: bool) -> Self {
277 if !use_colors {
278 return Self {
279 bullet: String::new(),
280 label: String::new(),
281 path: String::new(),
282 stat_added: String::new(),
283 stat_removed: String::new(),
284 line_added: String::new(),
285 line_removed: String::new(),
286 line_context: String::new(),
287 line_header: String::new(),
288 line_number: String::new(),
289 reset: String::new(),
290 };
291 }
292
293 let reset = format!("{}", Reset.render());
294 Self {
295 bullet: format!("{}", palette.bullet.render()),
296 label: format!("{}", palette.label.render()),
297 path: format!("{}", palette.path.render()),
298 stat_added: format!("{}", palette.stat_added.render()),
299 stat_removed: format!("{}", palette.stat_removed.render()),
300 line_added: format!("{}", palette.line_added.render()),
301 line_removed: format!("{}", palette.line_removed.render()),
302 line_context: format!("{}", palette.line_context.render()),
303 line_header: format!("{}", palette.line_header.render()),
304 line_number: format!("{}", palette.line_number.render()),
305 reset,
306 }
307 }
308}
309
310impl DiffRenderer {
311 pub fn new(show_line_numbers: bool, context_lines: usize, use_colors: bool) -> Self {
313 Self::with_options(DiffRendererOptions { show_line_numbers, context_lines, use_colors })
314 }
315
316 pub fn with_options(options: DiffRendererOptions) -> Self {
318 let palette = GitDiffPalette::new(options.use_colors);
319 let cached_styles = CachedStyles::new(&palette, options.use_colors);
320 Self {
321 show_line_numbers: options.show_line_numbers,
322 context_lines: options.context_lines,
323 use_colors: options.use_colors,
324 cached_styles,
325 }
326 }
327
328 pub fn with_git_config(
330 show_line_numbers: bool,
331 context_lines: usize,
332 use_colors: bool,
333 config: &GitColorConfig,
334 ) -> Self {
335 Self::with_git_config_options(DiffRendererOptions { show_line_numbers, context_lines, use_colors }, config)
336 }
337
338 pub fn with_git_config_options(options: DiffRendererOptions, config: &GitColorConfig) -> Self {
340 let palette = GitDiffPalette::from_git_config(config, options.use_colors);
341 let cached_styles = CachedStyles::new(&palette, options.use_colors);
342 Self {
343 show_line_numbers: options.show_line_numbers,
344 context_lines: options.context_lines,
345 use_colors: options.use_colors,
346 cached_styles,
347 }
348 }
349
350 pub fn render_diff(&self, diff: &FileDiff) -> String {
352 let estimated_size = 100 + diff.lines.len() * 80;
354 let mut output = String::with_capacity(estimated_size);
355
356 output.push_str("─ ");
358 output.push_str(&self.render_summary(diff));
359 output.push('\n');
360
361 for line in &diff.lines {
362 self.render_line_into(&mut output, line);
363 output.push('\n');
364 }
365
366 output
367 }
368
369 fn render_diff_bounded(&self, diff: &FileDiff, max_rows: usize) -> String {
370 let mut output = String::with_capacity(100 + max_rows.saturating_mul(80));
371 output.push_str("─ ");
372 output.push_str(&self.render_summary(diff));
373 output.push('\n');
374
375 let (head_count, tail_count, omitted) = bounded_window(diff.lines.len(), max_rows);
376 for line in &diff.lines[..head_count] {
377 self.render_line_into(&mut output, line);
378 output.push('\n');
379 }
380 if omitted > 0 {
381 let omission = DiffLine {
382 line_type: DiffLineType::Header,
383 content: format!("... {omitted} lines omitted ..."),
384 line_number_old: None,
385 line_number_new: None,
386 };
387 self.render_line_into(&mut output, &omission);
388 output.push('\n');
389 }
390 if tail_count > 0 {
391 for line in &diff.lines[diff.lines.len() - tail_count..] {
392 self.render_line_into(&mut output, line);
393 output.push('\n');
394 }
395 }
396
397 output
398 }
399
400 fn render_summary(&self, diff: &FileDiff) -> String {
401 if !self.use_colors {
402 return format!("▸ Edit {} (+{} -{})", diff.file_path, diff.stats.additions, diff.stats.deletions);
403 }
404
405 let estimated_size = 50 + diff.file_path.len();
407 let mut output = String::with_capacity(estimated_size);
408
409 output.push_str(&self.cached_styles.bullet);
411 output.push('▸');
412 output.push_str(&self.cached_styles.reset);
413 output.push(' ');
414
415 output.push_str(&self.cached_styles.label);
417 output.push_str("Edit");
418 output.push_str(&self.cached_styles.reset);
419 output.push(' ');
420
421 output.push_str(&self.cached_styles.path);
423 output.push_str(&diff.file_path);
424 output.push_str(&self.cached_styles.reset);
425 output.push(' ');
426
427 output.push('(');
429
430 output.push_str(&self.cached_styles.stat_added);
432 output.push('+');
433 use std::fmt::Write as FmtWrite;
434 let _ = write!(output, "{}", diff.stats.additions);
435 output.push_str(&self.cached_styles.reset);
436 output.push(' ');
437
438 output.push_str(&self.cached_styles.stat_removed);
440 output.push('-');
441 let _ = write!(output, "{}", diff.stats.deletions);
442 output.push_str(&self.cached_styles.reset);
443 output.push(')');
444
445 output
446 }
447
448 fn render_line_into(&self, output: &mut String, line: &DiffLine) {
450 let (style_code, prefix, line_number) = match line.line_type {
451 DiffLineType::Added => (&self.cached_styles.line_added, "+", line.line_number_new),
452 DiffLineType::Removed => (&self.cached_styles.line_removed, "-", line.line_number_old),
453 DiffLineType::Context => {
454 (&self.cached_styles.line_context, " ", line.line_number_new.or(line.line_number_old))
455 }
456 DiffLineType::Header => (&self.cached_styles.line_header, "", None),
457 };
458
459 if self.show_line_numbers {
460 if let Some(n) = line_number {
461 output.push_str(&self.cached_styles.line_number);
462 if n < 10 {
464 output.push_str(" ");
465 } else if n < 100 {
466 output.push_str(" ");
467 } else if n < 1000 {
468 output.push(' ');
469 }
470 use std::fmt::Write as FmtWrite;
471 let _ = write!(output, "{n}");
472 output.push_str(&self.cached_styles.reset);
473 } else {
474 output.push_str(" ");
475 }
476 output.push(' ');
477 }
478
479 match line.line_type {
480 DiffLineType::Header => {
481 output.push_str(style_code);
482 output.push_str(&line.content);
483 output.push_str(&self.cached_styles.reset);
484 }
485 _ => {
486 output.push_str(style_code);
487 output.push_str(prefix);
488 output.push(' ');
489 if !line.content.is_empty() {
490 output.push_str(&line.content);
491 }
492 output.push_str(&self.cached_styles.reset);
493 }
494 }
495 }
496
497 pub fn generate_diff(&self, old_content: &str, new_content: &str, file_path: &str) -> FileDiff {
499 let document = DiffDocument::between(
500 old_content,
501 new_content,
502 SharedDiffOptions {
503 context_lines: self.context_lines,
504 missing_newline_hint: false,
505 ..SharedDiffOptions::default()
506 },
507 );
508
509 let mut lines = Vec::new();
510 let mut additions = 0;
511 let mut deletions = 0;
512
513 for hunk in &document.hunks {
514 lines.push(DiffLine {
515 line_type: DiffLineType::Header,
516 content: format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
517 line_number_old: None,
518 line_number_new: None,
519 });
520 for line in &hunk.lines {
521 let line_type = match line.kind {
522 SharedDiffLineKind::Addition => {
523 additions += 1;
524 DiffLineType::Added
525 }
526 SharedDiffLineKind::Deletion => {
527 deletions += 1;
528 DiffLineType::Removed
529 }
530 SharedDiffLineKind::Context => DiffLineType::Context,
531 };
532
533 lines.push(DiffLine {
534 line_type,
535 content: trim_line_ending(&line.text).to_owned(),
536 line_number_old: line.old_line,
537 line_number_new: line.new_line,
538 });
539 }
540 }
541
542 let changes = additions + deletions;
543
544 FileDiff {
545 file_path: file_path.to_owned(),
546 lines,
547 stats: DiffStats { additions, deletions, changes },
548 }
549 }
550}
551
552pub struct DiffChatRenderer {
555 diff_renderer: DiffRenderer,
556}
557
558impl DiffChatRenderer {
559 pub fn new(show_line_numbers: bool, context_lines: usize, use_colors: bool) -> Self {
561 Self::with_options(DiffRendererOptions { show_line_numbers, context_lines, use_colors })
562 }
563
564 pub fn with_options(options: DiffRendererOptions) -> Self {
566 Self { diff_renderer: DiffRenderer::with_options(options) }
567 }
568
569 pub fn with_git_config(
571 show_line_numbers: bool,
572 context_lines: usize,
573 use_colors: bool,
574 config: &GitColorConfig,
575 ) -> Self {
576 Self::with_git_config_options(DiffRendererOptions { show_line_numbers, context_lines, use_colors }, config)
577 }
578
579 pub fn with_git_config_options(options: DiffRendererOptions, config: &GitColorConfig) -> Self {
581 Self {
582 diff_renderer: DiffRenderer::with_git_config_options(options, config),
583 }
584 }
585
586 pub fn render_file_change(&self, file_path: &Path, old_content: &str, new_content: &str) -> String {
588 let diff = self
589 .diff_renderer
590 .generate_diff(old_content, new_content, &file_path.to_string_lossy());
591 self.diff_renderer.render_diff_bounded(&diff, diff_constants::MAX_PREVIEW_LINES)
592 }
593
594 pub fn render_multiple_changes(&self, changes: Vec<(String, String, String)>) -> String {
596 let estimated_size = changes.len() * 512; let mut output = String::with_capacity(estimated_size);
601
602 let _ = write!(output, "\nMultiple File Changes ({} files)\n", changes.len());
603 output.push_str(&"═".repeat(60));
604 output.push_str("\n\n");
605
606 let mut remaining_rows = diff_constants::MAX_TOTAL_DIFF_LINES;
607 for (file_path, old_content, new_content) in changes {
608 let diff = self.diff_renderer.generate_diff(&old_content, &new_content, &file_path);
609 let file_limit = remaining_rows.min(diff_constants::MAX_PREVIEW_LINES);
610 output.push_str(&self.diff_renderer.render_diff_bounded(&diff, file_limit));
611 remaining_rows = remaining_rows.saturating_sub(diff.lines.len().min(file_limit));
612 }
613
614 output
615 }
616
617 fn check_suppression_with_cache(&self, changes: &[(String, String, String)]) -> SuppressionResult {
619 let file_count = changes.len();
620
621 if file_count > diff_constants::MAX_INLINE_DIFF_FILES {
623 let (file_stats, total_additions, total_deletions) = self.estimate_stats_lightweight(changes);
625 return SuppressionResult::suppressed(DiffSuppressionCheck::suppressed(
626 format!("Too many files changed ({} files, max {})", file_count, diff_constants::MAX_INLINE_DIFF_FILES),
627 file_count,
628 0, total_additions,
630 total_deletions,
631 file_stats,
632 ));
633 }
634
635 let mut total_lines = 0usize;
636 let mut total_additions = 0usize;
637 let mut total_deletions = 0usize;
638 let mut file_stats = Vec::with_capacity(file_count);
639 let mut suppression_reason: Option<String> = None;
640
641 for (file_path, old_content, new_content) in changes {
642 let diff = self.diff_renderer.generate_diff(old_content, new_content, file_path);
643
644 total_lines += diff.lines.len();
645 total_additions += diff.stats.additions;
646 total_deletions += diff.stats.deletions;
647
648 file_stats.push(FileChangeStats {
649 path: file_path.clone(),
650 additions: diff.stats.additions,
651 deletions: diff.stats.deletions,
652 });
653
654 if suppression_reason.is_none() {
656 if diff.stats.changes > diff_constants::MAX_SINGLE_FILE_CHANGES {
657 suppression_reason = Some(format!(
658 "Single file exceeds change limit (max {} changes per file)",
659 diff_constants::MAX_SINGLE_FILE_CHANGES
660 ));
661 } else if total_lines > diff_constants::MAX_TOTAL_DIFF_LINES {
662 suppression_reason = Some(format!(
663 "Too many diff lines ({} lines, max {})",
664 total_lines,
665 diff_constants::MAX_TOTAL_DIFF_LINES
666 ));
667 }
668 }
669 }
670
671 if let Some(reason) = suppression_reason {
672 SuppressionResult::suppressed(DiffSuppressionCheck::suppressed(
673 reason,
674 file_count,
675 total_lines,
676 total_additions,
677 total_deletions,
678 file_stats,
679 ))
680 } else {
681 SuppressionResult {
682 check: DiffSuppressionCheck::no_suppression(
683 file_count,
684 total_lines,
685 total_additions,
686 total_deletions,
687 file_stats,
688 ),
689 }
690 }
691 }
692
693 fn estimate_stats_lightweight(&self, changes: &[(String, String, String)]) -> (Vec<FileChangeStats>, usize, usize) {
695 let mut file_stats = Vec::with_capacity(changes.len());
696 let mut total_additions = 0usize;
697 let mut total_deletions = 0usize;
698
699 for (file_path, old_content, new_content) in changes {
700 let old_lines = old_content.lines().count();
702 let new_lines = new_content.lines().count();
703 let (additions, deletions) = if new_lines >= old_lines {
704 (new_lines - old_lines, 0)
705 } else {
706 (0, old_lines - new_lines)
707 };
708
709 total_additions += additions;
710 total_deletions += deletions;
711
712 file_stats.push(FileChangeStats { path: file_path.clone(), additions, deletions });
713 }
714
715 (file_stats, total_additions, total_deletions)
716 }
717
718 pub fn check_suppression(&self, changes: &[(String, String, String)]) -> DiffSuppressionCheck {
720 self.check_suppression_with_cache(changes).check
721 }
722
723 pub fn render_suppressed_summary(&self, check: &DiffSuppressionCheck) -> String {
725 let estimated_size = 256 + (check.file_stats.len() * 100);
727 let mut output = String::with_capacity(estimated_size);
728
729 output.push_str("\n[WARN] ");
731 output.push_str(diff_constants::SUPPRESSION_MESSAGE);
732 output.push_str("\n\n");
733
734 output.push_str("Summary: ");
736 use std::fmt::Write as FmtWrite;
737 let _ = write!(output, "{} file(s) changed", check.file_count);
738
739 if check.total_additions > 0 || check.total_deletions > 0 {
740 output.push_str(" (");
741
742 if check.total_additions > 0 {
744 if self.diff_renderer.use_colors {
745 output.push_str(&self.diff_renderer.cached_styles.stat_added);
746 }
747 output.push('+');
748 let _ = write!(output, "{}", check.total_additions);
749 if self.diff_renderer.use_colors {
750 output.push_str(&self.diff_renderer.cached_styles.reset);
751 }
752 }
753
754 if check.total_additions > 0 && check.total_deletions > 0 {
755 output.push(' ');
756 }
757
758 if check.total_deletions > 0 {
760 if self.diff_renderer.use_colors {
761 output.push_str(&self.diff_renderer.cached_styles.stat_removed);
762 }
763 output.push('-');
764 let _ = write!(output, "{}", check.total_deletions);
765 if self.diff_renderer.use_colors {
766 output.push_str(&self.diff_renderer.cached_styles.reset);
767 }
768 }
769
770 output.push(')');
771 }
772 output.push('\n');
773
774 if !check.file_stats.is_empty() {
776 output.push_str("\nChanged files:\n");
777 let max_files_to_show = diff_constants::MAX_FILES_IN_SUMMARY;
778 for (i, stat) in check.file_stats.iter().enumerate() {
779 if i >= max_files_to_show {
780 let remaining = check.file_stats.len() - max_files_to_show;
781 let _ = writeln!(output, " ... and {remaining} more file(s)");
782 break;
783 }
784
785 output.push_str(" • ");
786 output.push_str(&stat.path);
787 output.push_str(" (");
788
789 if self.diff_renderer.use_colors {
790 if stat.additions > 0 {
792 output.push_str(&self.diff_renderer.cached_styles.stat_added);
793 output.push('+');
794 let _ = write!(output, "{}", stat.additions);
795 output.push_str(&self.diff_renderer.cached_styles.reset);
796 }
797
798 if stat.additions > 0 && stat.deletions > 0 {
799 output.push(' ');
800 }
801
802 if stat.deletions > 0 {
804 output.push_str(&self.diff_renderer.cached_styles.stat_removed);
805 output.push('-');
806 let _ = write!(output, "{}", stat.deletions);
807 output.push_str(&self.diff_renderer.cached_styles.reset);
808 }
809 } else {
810 output.push('+');
811 let _ = write!(output, "{}", stat.additions);
812 output.push(' ');
813 output.push('-');
814 let _ = write!(output, "{}", stat.deletions);
815 }
816
817 output.push_str(")\n");
818 }
819 }
820
821 if let Some(reason) = &check.reason {
823 let _ = writeln!(output, "\nReason: {reason}");
824 }
825
826 output.push('\n');
828 output.push_str(diff_constants::SUPPRESSION_HINT);
829 output.push('\n');
830
831 output
832 }
833
834 pub fn render_operation_summary(&self, operation: &str, files_affected: usize, success: bool) -> String {
836 let status_indicator = if success { "✓" } else { "✗" };
837 let status_label = if success { "Success" } else { "Failure" };
838 let mut summary = format!("\n{status_indicator} [{status_label}] {operation}\n");
839 let _ = writeln!(summary, "└─ {files_affected} file(s) affected");
840
841 if success {
842 summary.push_str(" Operation completed successfully\n");
843 } else {
844 summary.push_str(" Operation completed with errors\n");
845 }
846
847 summary
848 }
849}
850
851fn bounded_window(total: usize, max_rows: usize) -> (usize, usize, usize) {
852 if total <= max_rows {
853 return (total, 0, 0);
854 }
855 let retained = max_rows.saturating_sub(1);
856 let head = retained.saturating_add(1) / 2;
857 let tail = retained / 2;
858 (head, tail, total.saturating_sub(head + tail))
859}
860
861fn trim_line_ending(text: &str) -> &str {
862 text.strip_suffix("\r\n")
863 .or_else(|| text.strip_suffix('\n'))
864 .or_else(|| text.strip_suffix('\r'))
865 .unwrap_or(text)
866}
867
868pub fn generate_unified_diff(old_content: &str, new_content: &str, filename: &str) -> String {
870 let old_label = format!("a/{filename}");
871 let new_label = format!("b/{filename}");
872 let options = SharedDiffOptions {
873 context_lines: 3,
874 old_label: Some(&old_label),
875 new_label: Some(&new_label),
876 missing_newline_hint: false,
877 ..SharedDiffOptions::default()
878 };
879 format_unified_diff(old_content, new_content, options)
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 #[test]
887 fn named_options_match_positional_constructors() {
888 let options = DiffRendererOptions {
889 show_line_numbers: true,
890 context_lines: 3,
891 use_colors: false,
892 };
893 let from_named = DiffRenderer::with_options(options);
894 let from_positional = DiffRenderer::new(true, 3, false);
895 assert_eq!(
896 from_named.render_diff(&from_positional.generate_diff("a", "b", "f")),
897 from_positional.render_diff(&from_positional.generate_diff("a", "b", "f"))
898 );
899
900 let chat_named = DiffChatRenderer::with_options(options);
901 let preview_named = chat_named.render_file_change(Path::new("f"), "a", "b");
902 let chat_positional = DiffChatRenderer::new(true, 3, false);
903 let preview_positional = chat_positional.render_file_change(Path::new("f"), "a", "b");
904 assert_eq!(preview_named, preview_positional);
905
906 let config = GitColorConfig::default();
907 let git_named = DiffRenderer::with_git_config_options(options, &config);
908 let git_positional = DiffRenderer::with_git_config(true, 3, false, &config);
909 assert_eq!(
910 git_named.render_diff(&git_positional.generate_diff("a", "b", "f")),
911 git_positional.render_diff(&git_positional.generate_diff("a", "b", "f"))
912 );
913 }
914
915 #[test]
916 fn test_suppression_check_no_suppression() {
917 let renderer = DiffChatRenderer::new(true, 3, false);
918 let changes = vec![
919 ("file1.rs".to_owned(), "old1".to_owned(), "new1".to_owned()),
920 ("file2.rs".to_owned(), "old2".to_owned(), "new2".to_owned()),
921 ];
922
923 let check = renderer.check_suppression(&changes);
924 assert!(!check.should_suppress);
925 assert!(check.reason.is_none());
926 assert_eq!(check.file_count, 2);
927 }
928
929 #[test]
930 fn test_suppression_check_too_many_files() {
931 let renderer = DiffChatRenderer::new(true, 3, false);
932 let mut changes = Vec::new();
934 for i in 0..(diff_constants::MAX_INLINE_DIFF_FILES + 5) {
935 changes.push((format!("file{i}.rs"), format!("old{i}"), format!("new{i}")));
936 }
937
938 let check = renderer.check_suppression(&changes);
939 assert!(check.should_suppress);
940 assert!(check.reason.is_some());
941 assert!(check.reason.as_ref().unwrap().contains("Too many files"));
942 }
943
944 #[test]
945 fn test_suppression_check_large_single_file() {
946 let renderer = DiffChatRenderer::new(true, 3, false);
947
948 let old_content: String = (0..300).map(|i| format!("old line {i}\n")).collect();
950 let new_content: String = (0..300).map(|i| format!("new line {i}\n")).collect();
951
952 let changes = vec![("large_file.rs".to_owned(), old_content, new_content)];
953
954 let check = renderer.check_suppression(&changes);
955 assert!(check.should_suppress);
956 assert!(check.reason.is_some());
957 }
958
959 #[test]
960 fn test_render_suppressed_summary() {
961 let renderer = DiffChatRenderer::new(true, 3, false);
962 let file_stats = vec![
963 FileChangeStats {
964 path: "file1.rs".to_owned(),
965 additions: 20,
966 deletions: 10,
967 },
968 FileChangeStats {
969 path: "file2.rs".to_owned(),
970 additions: 30,
971 deletions: 20,
972 },
973 ];
974 let check = DiffSuppressionCheck::suppressed("Test reason".to_string(), 5, 100, 50, 30, file_stats);
975
976 let output = renderer.render_suppressed_summary(&check);
977 assert!(output.contains(diff_constants::SUPPRESSION_MESSAGE));
978 assert!(output.contains("5 file(s) changed"));
979 assert!(output.contains("50")); assert!(output.contains("30")); assert!(output.contains("Test reason"));
982 assert!(output.contains("file1.rs"));
983 assert!(output.contains("file2.rs"));
984 }
985
986 #[test]
987 fn test_render_multiple_changes_uses_bounded_preview() {
988 let renderer = DiffChatRenderer::new(true, 3, false);
989
990 let old = (0..300).map(|i| format!("old-{i}\n")).collect::<String>();
991 let new = (0..300).map(|i| format!("new-{i}\n")).collect::<String>();
992 let changes = vec![("large.rs".to_owned(), old, new)];
993
994 let output = renderer.render_multiple_changes(changes);
995 assert!(!output.contains(diff_constants::SUPPRESSION_MESSAGE));
996 assert!(output.contains("old-0"));
997 assert!(output.contains("new-299"));
998 assert!(output.contains("lines omitted"));
999 }
1000
1001 #[test]
1002 fn test_render_multiple_changes_without_suppression() {
1003 let renderer = DiffChatRenderer::new(true, 3, false);
1004 let changes = vec![
1005 ("file1.rs".to_string(), "a".to_string(), "b".to_string()),
1006 ("file2.rs".to_string(), "c".to_string(), "d".to_string()),
1007 ];
1008
1009 let output = renderer.render_multiple_changes(changes);
1010 assert!(output.contains("Multiple File Changes"));
1011 assert!(!output.contains(diff_constants::SUPPRESSION_MESSAGE));
1012 }
1013
1014 #[test]
1015 fn test_render_file_change_includes_summary_and_hunk_header() {
1016 let renderer = DiffChatRenderer::new(true, 3, false);
1017 let output = renderer.render_file_change(Path::new("file.rs"), "a\nb\n", "a\nc\n");
1018
1019 assert!(output.contains("▸ Edit file.rs (+1 -1)"));
1020 assert!(output.contains("@@ -1 +1 @@"));
1021 }
1022
1023 #[test]
1024 fn test_render_file_change_keeps_blank_added_line_spacing() {
1025 let renderer = DiffChatRenderer::new(false, 3, false);
1026 let output = renderer.render_file_change(
1027 Path::new("Cargo.toml"),
1028 "[package]\n[dependencies]\n",
1029 "[package]\n[dependencies]\n\n[workspace]\n",
1030 );
1031
1032 assert!(output.contains("\n+ \n+ [workspace]\n"));
1033 }
1034
1035 #[test]
1036 fn test_render_file_change_normalizes_carriage_return_line_endings() {
1037 let renderer = DiffChatRenderer::new(false, 3, false);
1038 let output = renderer.render_file_change(Path::new("file.rs"), "old\r\n", "new\r\n");
1039
1040 assert!(!output.contains('\r'));
1041 assert!(output.contains("- old\n+ new\n"));
1042 }
1043
1044 #[test]
1045 fn test_render_file_change_bounds_large_preview() {
1046 let renderer = DiffChatRenderer::new(false, 3, false);
1047 let old = (0..300).map(|index| format!("old-{index}\n")).collect::<String>();
1048 let new = (0..300).map(|index| format!("new-{index}\n")).collect::<String>();
1049
1050 let output = renderer.render_file_change(Path::new("large.rs"), &old, &new);
1051
1052 assert!(output.contains("old-0"));
1053 assert!(output.contains("new-299"));
1054 assert!(output.contains("lines omitted"));
1055 assert!(output.lines().count() <= diff_constants::MAX_PREVIEW_LINES + 1);
1056 }
1057}