1#![allow(clippy::let_underscore_must_use)]
2
3use crate::config::constants::diff as diff_constants;
4use crate::ui::git_config::GitColorConfig;
5use crate::utils::style_helpers::style_from_color_name;
6use anstyle::{Reset, Style};
7use std::fmt::Write as _;
8use std::path::Path;
9use vtcode_commons::styling::DiffColorPalette;
10
11pub(crate) struct GitDiffPalette {
12 pub(crate) bullet: Style,
13 pub(crate) label: Style,
14 pub(crate) path: Style,
15 pub(crate) stat_added: Style,
16 pub(crate) stat_removed: Style,
17 pub(crate) line_added: Style,
18 pub(crate) line_removed: Style,
19 pub(crate) line_context: Style,
20 pub(crate) line_header: Style,
21 pub(crate) line_number: Style,
22}
23
24fn strip_diff_background(style: Style) -> Style {
25 style.bg_color(None)
26}
27
28impl GitDiffPalette {
29 fn new(use_colors: bool) -> Self {
30 let palette = DiffColorPalette::default();
32 let added = palette.added_style();
33 let removed = palette.removed_style();
34 let header = palette.header_style();
35
36 Self {
37 bullet: if use_colors {
38 style_from_color_name("cyan")
39 } else {
40 Style::new()
41 }, label: Style::new(), path: Style::new(), stat_added: strip_diff_background(added),
45 stat_removed: strip_diff_background(removed),
46 line_added: strip_diff_background(added),
47 line_removed: strip_diff_background(removed),
48 line_context: if use_colors {
49 Style::new().dimmed()
50 } else {
51 Style::new()
52 },
53 line_header: strip_diff_background(header),
54 line_number: if use_colors {
55 style_from_color_name("cyan")
56 } else {
57 Style::new()
58 }, }
60 }
61
62 fn from_git_config(config: &GitColorConfig, use_colors: bool) -> Self {
64 if !use_colors {
65 return Self::new(false);
66 }
67
68 let palette = DiffColorPalette::default();
70 let added = palette.added_style();
71 let removed = palette.removed_style();
72 let header = palette.header_style();
73
74 Self {
75 bullet: style_from_color_name("cyan"),
76 label: Style::new(),
77 path: Style::new(),
78 stat_added: strip_diff_background(added),
79 stat_removed: strip_diff_background(removed),
80 line_added: strip_diff_background(added),
81 line_removed: strip_diff_background(removed),
82 line_context: strip_diff_background(config.diff_context),
83 line_header: strip_diff_background(header),
84 line_number: style_from_color_name("cyan"),
85 }
86 }
87}
88
89#[derive(Debug, Clone)]
90pub struct DiffLine {
91 pub line_type: DiffLineType,
92 pub content: String,
93 pub line_number_old: Option<u32>,
94 pub line_number_new: Option<u32>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum DiffLineType {
99 Added,
100 Removed,
101 Context,
102 Header,
103}
104
105#[derive(Debug)]
106pub struct FileDiff {
107 pub file_path: String,
108 pub lines: Vec<DiffLine>,
109 pub stats: DiffStats,
110}
111
112#[derive(Debug, Clone)]
113pub struct DiffStats {
114 pub additions: usize,
115 pub deletions: usize,
116 pub changes: usize,
117}
118
119#[derive(Debug, Clone)]
121pub struct DiffSuppressionCheck {
122 pub should_suppress: bool,
124 pub reason: Option<String>,
126 pub file_count: usize,
128 pub total_lines: usize,
130 pub total_additions: usize,
132 pub total_deletions: usize,
134 pub file_stats: Vec<FileChangeStats>,
136}
137
138#[derive(Debug, Clone)]
140pub struct FileChangeStats {
141 pub path: String,
142 pub additions: usize,
143 pub deletions: usize,
144}
145
146#[derive(Debug)]
148struct DiffCacheEntry {
149 diff: FileDiff,
150}
151
152pub struct SuppressionResult {
154 pub check: DiffSuppressionCheck,
155 cached_diffs: Option<Vec<DiffCacheEntry>>,
157}
158
159impl SuppressionResult {
160 fn suppressed(check: DiffSuppressionCheck) -> Self {
161 Self {
162 check,
163 cached_diffs: None,
164 }
165 }
166
167 fn not_suppressed(check: DiffSuppressionCheck, diffs: Vec<DiffCacheEntry>) -> Self {
168 Self {
169 check,
170 cached_diffs: Some(diffs),
171 }
172 }
173}
174
175impl DiffSuppressionCheck {
176 pub fn no_suppression(
178 file_count: usize,
179 total_lines: usize,
180 additions: usize,
181 deletions: usize,
182 file_stats: Vec<FileChangeStats>,
183 ) -> Self {
184 Self {
185 should_suppress: false,
186 reason: None,
187 file_count,
188 total_lines,
189 total_additions: additions,
190 total_deletions: deletions,
191 file_stats,
192 }
193 }
194
195 pub fn suppressed(
197 reason: String,
198 file_count: usize,
199 total_lines: usize,
200 additions: usize,
201 deletions: usize,
202 file_stats: Vec<FileChangeStats>,
203 ) -> Self {
204 Self {
205 should_suppress: true,
206 reason: Some(reason),
207 file_count,
208 total_lines,
209 total_additions: additions,
210 total_deletions: deletions,
211 file_stats,
212 }
213 }
214}
215
216pub struct DiffRenderer {
217 show_line_numbers: bool,
218 context_lines: usize,
219 use_colors: bool,
220 #[expect(dead_code)]
221 pub(crate) palette: GitDiffPalette,
222 cached_styles: CachedStyles,
224}
225
226struct CachedStyles {
228 bullet: String,
229 label: String,
230 path: String,
231 stat_added: String,
232 stat_removed: String,
233 line_added: String,
234 line_removed: String,
235 line_context: String,
236 line_header: String,
237 line_number: String,
238 reset: String,
239}
240
241impl CachedStyles {
242 fn new(palette: &GitDiffPalette, use_colors: bool) -> Self {
243 if !use_colors {
244 return Self {
245 bullet: String::new(),
246 label: String::new(),
247 path: String::new(),
248 stat_added: String::new(),
249 stat_removed: String::new(),
250 line_added: String::new(),
251 line_removed: String::new(),
252 line_context: String::new(),
253 line_header: String::new(),
254 line_number: String::new(),
255 reset: String::new(),
256 };
257 }
258
259 let reset = format!("{}", Reset.render());
260 Self {
261 bullet: format!("{}", palette.bullet.render()),
262 label: format!("{}", palette.label.render()),
263 path: format!("{}", palette.path.render()),
264 stat_added: format!("{}", palette.stat_added.render()),
265 stat_removed: format!("{}", palette.stat_removed.render()),
266 line_added: format!("{}", palette.line_added.render()),
267 line_removed: format!("{}", palette.line_removed.render()),
268 line_context: format!("{}", palette.line_context.render()),
269 line_header: format!("{}", palette.line_header.render()),
270 line_number: format!("{}", palette.line_number.render()),
271 reset,
272 }
273 }
274}
275
276impl DiffRenderer {
277 pub fn new(show_line_numbers: bool, context_lines: usize, use_colors: bool) -> Self {
278 let palette = GitDiffPalette::new(use_colors);
279 let cached_styles = CachedStyles::new(&palette, use_colors);
280 Self {
281 show_line_numbers,
282 context_lines,
283 use_colors,
284 palette,
285 cached_styles,
286 }
287 }
288
289 pub fn with_git_config(
291 show_line_numbers: bool,
292 context_lines: usize,
293 use_colors: bool,
294 config: &GitColorConfig,
295 ) -> Self {
296 let palette = GitDiffPalette::from_git_config(config, use_colors);
297 let cached_styles = CachedStyles::new(&palette, use_colors);
298 Self {
299 show_line_numbers,
300 context_lines,
301 use_colors,
302 palette,
303 cached_styles,
304 }
305 }
306
307 pub fn render_diff(&self, diff: &FileDiff) -> String {
308 let estimated_size = 100 + diff.lines.len() * 80;
310 let mut output = String::with_capacity(estimated_size);
311
312 output.push_str("─ ");
314 output.push_str(&self.render_summary(diff));
315 output.push('\n');
316
317 for line in &diff.lines {
318 self.render_line_into(&mut output, line);
319 output.push('\n');
320 }
321
322 output
323 }
324
325 fn render_summary(&self, diff: &FileDiff) -> String {
326 if !self.use_colors {
327 return format!(
328 "▸ Edit {} (+{} -{})",
329 diff.file_path, diff.stats.additions, diff.stats.deletions
330 );
331 }
332
333 let estimated_size = 50 + diff.file_path.len();
335 let mut output = String::with_capacity(estimated_size);
336
337 output.push_str(&self.cached_styles.bullet);
339 output.push('▸');
340 output.push_str(&self.cached_styles.reset);
341 output.push(' ');
342
343 output.push_str(&self.cached_styles.label);
345 output.push_str("Edit");
346 output.push_str(&self.cached_styles.reset);
347 output.push(' ');
348
349 output.push_str(&self.cached_styles.path);
351 output.push_str(&diff.file_path);
352 output.push_str(&self.cached_styles.reset);
353 output.push(' ');
354
355 output.push('(');
357
358 output.push_str(&self.cached_styles.stat_added);
360 output.push('+');
361 use std::fmt::Write as FmtWrite;
362 let _ = write!(output, "{}", diff.stats.additions);
363 output.push_str(&self.cached_styles.reset);
364 output.push(' ');
365
366 output.push_str(&self.cached_styles.stat_removed);
368 output.push('-');
369 let _ = write!(output, "{}", diff.stats.deletions);
370 output.push_str(&self.cached_styles.reset);
371 output.push(')');
372
373 output
374 }
375
376 fn render_line_into(&self, output: &mut String, line: &DiffLine) {
378 let (style_code, prefix, line_number) = match line.line_type {
379 DiffLineType::Added => (&self.cached_styles.line_added, "+", line.line_number_new),
380 DiffLineType::Removed => (&self.cached_styles.line_removed, "-", line.line_number_old),
381 DiffLineType::Context => (
382 &self.cached_styles.line_context,
383 " ",
384 line.line_number_new.or(line.line_number_old),
385 ),
386 DiffLineType::Header => (&self.cached_styles.line_header, "", None),
387 };
388
389 if self.show_line_numbers {
390 if let Some(n) = line_number {
391 output.push_str(&self.cached_styles.line_number);
392 if n < 10 {
394 output.push_str(" ");
395 } else if n < 100 {
396 output.push_str(" ");
397 } else if n < 1000 {
398 output.push(' ');
399 }
400 use std::fmt::Write as FmtWrite;
401 let _ = write!(output, "{}", n);
402 output.push_str(&self.cached_styles.reset);
403 } else {
404 output.push_str(" ");
405 }
406 output.push(' ');
407 }
408
409 match line.line_type {
410 DiffLineType::Header => {
411 output.push_str(style_code);
412 output.push_str(&line.content);
413 output.push_str(&self.cached_styles.reset);
414 }
415 _ => {
416 output.push_str(style_code);
417 output.push_str(prefix);
418 output.push(' ');
419 if !line.content.is_empty() {
420 output.push_str(&line.content);
421 }
422 output.push_str(&self.cached_styles.reset);
423 }
424 }
425 }
426
427 #[expect(dead_code)]
428 pub(crate) fn paint(&self, style: &Style, text: &str) -> String {
429 if self.use_colors {
430 format!("{}{}{}", style.render(), text, Reset.render())
433 } else {
434 text.to_owned()
435 }
436 }
437
438 pub fn generate_diff(&self, old_content: &str, new_content: &str, file_path: &str) -> FileDiff {
439 let bundle = crate::utils::diff::compute_diff_with_theme(
440 old_content,
441 new_content,
442 crate::utils::diff::DiffOptions {
443 context_lines: self.context_lines,
444 old_label: None,
445 new_label: None,
446 missing_newline_hint: false,
447 },
448 );
449
450 let mut lines = Vec::new();
451 let mut additions = 0;
452 let mut deletions = 0;
453
454 for hunk in &bundle.hunks {
455 lines.push(DiffLine {
456 line_type: DiffLineType::Header,
457 content: format!("@@ -{} +{} @@", hunk.old_start, hunk.new_start),
458 line_number_old: None,
459 line_number_new: None,
460 });
461 for line in &hunk.lines {
462 let line_type = match line.kind {
463 crate::utils::diff::DiffLineKind::Addition => {
464 additions += 1;
465 DiffLineType::Added
466 }
467 crate::utils::diff::DiffLineKind::Deletion => {
468 deletions += 1;
469 DiffLineType::Removed
470 }
471 crate::utils::diff::DiffLineKind::Context => DiffLineType::Context,
472 };
473
474 lines.push(DiffLine {
475 line_type,
476 content: line.text.trim_end_matches('\n').to_string(),
477 line_number_old: line.old_line,
478 line_number_new: line.new_line,
479 });
480 }
481 }
482
483 let changes = additions + deletions;
484
485 FileDiff {
486 file_path: file_path.to_owned(),
487 lines,
488 stats: DiffStats {
489 additions,
490 deletions,
491 changes,
492 },
493 }
494 }
495}
496
497pub struct DiffChatRenderer {
498 diff_renderer: DiffRenderer,
499}
500
501impl DiffChatRenderer {
502 pub fn new(show_line_numbers: bool, context_lines: usize, use_colors: bool) -> Self {
503 Self {
504 diff_renderer: DiffRenderer::new(show_line_numbers, context_lines, use_colors),
505 }
506 }
507
508 pub fn with_git_config(
510 show_line_numbers: bool,
511 context_lines: usize,
512 use_colors: bool,
513 config: &GitColorConfig,
514 ) -> Self {
515 Self {
516 diff_renderer: DiffRenderer::with_git_config(
517 show_line_numbers,
518 context_lines,
519 use_colors,
520 config,
521 ),
522 }
523 }
524
525 pub fn render_file_change(
526 &self,
527 file_path: &Path,
528 old_content: &str,
529 new_content: &str,
530 ) -> String {
531 let diff = self.diff_renderer.generate_diff(
532 old_content,
533 new_content,
534 &file_path.to_string_lossy(),
535 );
536 self.diff_renderer.render_diff(&diff)
537 }
538
539 pub fn render_multiple_changes(&self, changes: Vec<(String, String, String)>) -> String {
540 let result = self.check_suppression_with_cache(&changes);
542
543 if result.check.should_suppress {
544 return self.render_suppressed_summary(&result.check);
545 }
546
547 let estimated_size = changes.len() * 512; let mut output = String::with_capacity(estimated_size);
550
551 let _ = write!(
552 output,
553 "\nMultiple File Changes ({} files)\n",
554 changes.len()
555 );
556 output.push_str(&"═".repeat(60));
557 output.push_str("\n\n");
558
559 if let Some(cached_diffs) = result.cached_diffs {
561 for entry in cached_diffs {
562 output.push_str(&self.diff_renderer.render_diff(&entry.diff));
563 }
564 }
565
566 output
567 }
568
569 fn check_suppression_with_cache(
572 &self,
573 changes: &[(String, String, String)],
574 ) -> SuppressionResult {
575 let file_count = changes.len();
576
577 if file_count > diff_constants::MAX_INLINE_DIFF_FILES {
579 let (file_stats, total_additions, total_deletions) =
581 self.estimate_stats_lightweight(changes);
582 return SuppressionResult::suppressed(DiffSuppressionCheck::suppressed(
583 format!(
584 "Too many files changed ({} files, max {})",
585 file_count,
586 diff_constants::MAX_INLINE_DIFF_FILES
587 ),
588 file_count,
589 0, total_additions,
591 total_deletions,
592 file_stats,
593 ));
594 }
595
596 let mut total_lines = 0usize;
597 let mut total_additions = 0usize;
598 let mut total_deletions = 0usize;
599 let mut file_stats = Vec::with_capacity(file_count);
600 let mut cached_diffs = Vec::with_capacity(file_count);
601 let mut suppression_reason: Option<String> = None;
602
603 for (file_path, old_content, new_content) in changes {
604 let diff = self
605 .diff_renderer
606 .generate_diff(old_content, new_content, file_path);
607
608 total_lines += diff.lines.len();
609 total_additions += diff.stats.additions;
610 total_deletions += diff.stats.deletions;
611
612 file_stats.push(FileChangeStats {
613 path: file_path.clone(),
614 additions: diff.stats.additions,
615 deletions: diff.stats.deletions,
616 });
617
618 if suppression_reason.is_none() {
620 if diff.stats.changes > diff_constants::MAX_SINGLE_FILE_CHANGES {
621 suppression_reason = Some(format!(
622 "Single file exceeds change limit (max {} changes per file)",
623 diff_constants::MAX_SINGLE_FILE_CHANGES
624 ));
625 } else if total_lines > diff_constants::MAX_TOTAL_DIFF_LINES {
626 suppression_reason = Some(format!(
627 "Too many diff lines ({} lines, max {})",
628 total_lines,
629 diff_constants::MAX_TOTAL_DIFF_LINES
630 ));
631 }
632 }
633
634 cached_diffs.push(DiffCacheEntry { diff });
636 }
637
638 if let Some(reason) = suppression_reason {
639 SuppressionResult::suppressed(DiffSuppressionCheck::suppressed(
640 reason,
641 file_count,
642 total_lines,
643 total_additions,
644 total_deletions,
645 file_stats,
646 ))
647 } else {
648 SuppressionResult::not_suppressed(
649 DiffSuppressionCheck::no_suppression(
650 file_count,
651 total_lines,
652 total_additions,
653 total_deletions,
654 file_stats,
655 ),
656 cached_diffs,
657 )
658 }
659 }
660
661 fn estimate_stats_lightweight(
663 &self,
664 changes: &[(String, String, String)],
665 ) -> (Vec<FileChangeStats>, usize, usize) {
666 let mut file_stats = Vec::with_capacity(changes.len());
667 let mut total_additions = 0usize;
668 let mut total_deletions = 0usize;
669
670 for (file_path, old_content, new_content) in changes {
671 let old_lines = old_content.lines().count();
673 let new_lines = new_content.lines().count();
674 let (additions, deletions) = if new_lines >= old_lines {
675 (new_lines - old_lines, 0)
676 } else {
677 (0, old_lines - new_lines)
678 };
679
680 total_additions += additions;
681 total_deletions += deletions;
682
683 file_stats.push(FileChangeStats {
684 path: file_path.clone(),
685 additions,
686 deletions,
687 });
688 }
689
690 (file_stats, total_additions, total_deletions)
691 }
692
693 pub fn check_suppression(&self, changes: &[(String, String, String)]) -> DiffSuppressionCheck {
695 self.check_suppression_with_cache(changes).check
696 }
697
698 pub fn render_suppressed_summary(&self, check: &DiffSuppressionCheck) -> String {
700 let estimated_size = 256 + (check.file_stats.len() * 100);
702 let mut output = String::with_capacity(estimated_size);
703
704 output.push_str("\n[WARN] ");
706 output.push_str(diff_constants::SUPPRESSION_MESSAGE);
707 output.push_str("\n\n");
708
709 output.push_str("Summary: ");
711 use std::fmt::Write as FmtWrite;
712 let _ = write!(output, "{} file(s) changed", check.file_count);
713
714 if check.total_additions > 0 || check.total_deletions > 0 && self.diff_renderer.use_colors {
715 output.push_str(" (");
716
717 if check.total_additions > 0 {
719 output.push_str(&self.diff_renderer.cached_styles.stat_added);
720 output.push('+');
721 let _ = write!(output, "{}", check.total_additions);
722 output.push_str(&self.diff_renderer.cached_styles.reset);
723 }
724
725 if check.total_additions > 0 && check.total_deletions > 0 {
726 output.push(' ');
727 }
728
729 if check.total_deletions > 0 {
731 output.push_str(&self.diff_renderer.cached_styles.stat_removed);
732 output.push('-');
733 let _ = write!(output, "{}", check.total_deletions);
734 output.push_str(&self.diff_renderer.cached_styles.reset);
735 }
736
737 output.push(')');
738 }
739 output.push('\n');
740
741 if !check.file_stats.is_empty() {
743 output.push_str("\nChanged files:\n");
744 let max_files_to_show = diff_constants::MAX_FILES_IN_SUMMARY;
745 for (i, stat) in check.file_stats.iter().enumerate() {
746 if i >= max_files_to_show {
747 let remaining = check.file_stats.len() - max_files_to_show;
748 let _ = writeln!(output, " ... and {} more file(s)", remaining);
749 break;
750 }
751
752 output.push_str(" • ");
753 output.push_str(&stat.path);
754 output.push_str(" (");
755
756 if self.diff_renderer.use_colors {
757 if stat.additions > 0 {
759 output.push_str(&self.diff_renderer.cached_styles.stat_added);
760 output.push('+');
761 let _ = write!(output, "{}", stat.additions);
762 output.push_str(&self.diff_renderer.cached_styles.reset);
763 }
764
765 if stat.additions > 0 && stat.deletions > 0 {
766 output.push(' ');
767 }
768
769 if stat.deletions > 0 {
771 output.push_str(&self.diff_renderer.cached_styles.stat_removed);
772 output.push('-');
773 let _ = write!(output, "{}", stat.deletions);
774 output.push_str(&self.diff_renderer.cached_styles.reset);
775 }
776 } else {
777 output.push('+');
778 let _ = write!(output, "{}", stat.additions);
779 output.push(' ');
780 output.push('-');
781 let _ = write!(output, "{}", stat.deletions);
782 }
783
784 output.push_str(")\n");
785 }
786 }
787
788 if let Some(reason) = &check.reason {
790 let _ = writeln!(output, "\nReason: {}", reason);
791 }
792
793 output.push('\n');
795 output.push_str(diff_constants::SUPPRESSION_HINT);
796 output.push('\n');
797
798 output
799 }
800
801 pub fn render_operation_summary(
802 &self,
803 operation: &str,
804 files_affected: usize,
805 success: bool,
806 ) -> String {
807 let status_indicator = if success { "✓" } else { "✗" };
808 let status_label = if success { "Success" } else { "Failure" };
809 let mut summary = format!("\n{} [{}] {}\n", status_indicator, status_label, operation);
810 let _ = writeln!(summary, "└─ {} file(s) affected", files_affected);
811
812 if success {
813 summary.push_str(" Operation completed successfully\n");
814 } else {
815 summary.push_str(" Operation completed with errors\n");
816 }
817
818 summary
819 }
820}
821
822pub fn generate_unified_diff(old_content: &str, new_content: &str, filename: &str) -> String {
823 let old_label = format!("a/{}", filename);
824 let new_label = format!("b/{}", filename);
825 let options = crate::utils::diff::DiffOptions {
826 context_lines: 3,
827 old_label: Some(&old_label),
828 new_label: Some(&new_label),
829 missing_newline_hint: false,
830 };
831 crate::utils::diff::format_unified_diff(old_content, new_content, options)
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 #[test]
839 fn test_suppression_check_no_suppression() {
840 let renderer = DiffChatRenderer::new(true, 3, false);
841 let changes = vec![
842 ("file1.rs".to_owned(), "old1".to_owned(), "new1".to_owned()),
843 ("file2.rs".to_owned(), "old2".to_owned(), "new2".to_owned()),
844 ];
845
846 let check = renderer.check_suppression(&changes);
847 assert!(!check.should_suppress);
848 assert!(check.reason.is_none());
849 assert_eq!(check.file_count, 2);
850 }
851
852 #[test]
853 fn test_suppression_check_too_many_files() {
854 let renderer = DiffChatRenderer::new(true, 3, false);
855 let mut changes = Vec::new();
857 for i in 0..(diff_constants::MAX_INLINE_DIFF_FILES + 5) {
858 changes.push((
859 format!("file{}.rs", i),
860 format!("old{}", i),
861 format!("new{}", i),
862 ));
863 }
864
865 let check = renderer.check_suppression(&changes);
866 assert!(check.should_suppress);
867 assert!(check.reason.is_some());
868 assert!(check.reason.as_ref().unwrap().contains("Too many files"));
869 }
870
871 #[test]
872 fn test_suppression_check_large_single_file() {
873 let renderer = DiffChatRenderer::new(true, 3, false);
874
875 let old_content: String = (0..300).map(|i| format!("old line {}\n", i)).collect();
877 let new_content: String = (0..300).map(|i| format!("new line {}\n", i)).collect();
878
879 let changes = vec![("large_file.rs".to_owned(), old_content, new_content)];
880
881 let check = renderer.check_suppression(&changes);
882 assert!(check.should_suppress);
883 assert!(check.reason.is_some());
884 }
885
886 #[test]
887 fn test_render_suppressed_summary() {
888 let renderer = DiffChatRenderer::new(true, 3, false);
889 let file_stats = vec![
890 FileChangeStats {
891 path: "file1.rs".to_owned(),
892 additions: 20,
893 deletions: 10,
894 },
895 FileChangeStats {
896 path: "file2.rs".to_owned(),
897 additions: 30,
898 deletions: 20,
899 },
900 ];
901 let check =
902 DiffSuppressionCheck::suppressed("Test reason".to_string(), 5, 100, 50, 30, file_stats);
903
904 let output = renderer.render_suppressed_summary(&check);
905 assert!(output.contains(diff_constants::SUPPRESSION_MESSAGE));
906 assert!(output.contains("5 file(s) changed"));
907 assert!(output.contains("50")); assert!(output.contains("30")); assert!(output.contains("Test reason"));
910 assert!(output.contains("file1.rs"));
911 assert!(output.contains("file2.rs"));
912 }
913
914 #[test]
915 fn test_render_multiple_changes_with_suppression() {
916 let renderer = DiffChatRenderer::new(true, 3, false);
917
918 let mut changes = Vec::new();
920 for i in 0..(diff_constants::MAX_INLINE_DIFF_FILES + 2) {
921 changes.push((
922 format!("file{}.rs", i),
923 "old".to_string(),
924 "new".to_string(),
925 ));
926 }
927
928 let output = renderer.render_multiple_changes(changes);
929 assert!(output.contains(diff_constants::SUPPRESSION_MESSAGE));
930 }
931
932 #[test]
933 fn test_render_multiple_changes_without_suppression() {
934 let renderer = DiffChatRenderer::new(true, 3, false);
935 let changes = vec![
936 ("file1.rs".to_string(), "a".to_string(), "b".to_string()),
937 ("file2.rs".to_string(), "c".to_string(), "d".to_string()),
938 ];
939
940 let output = renderer.render_multiple_changes(changes);
941 assert!(output.contains("Multiple File Changes"));
942 assert!(!output.contains(diff_constants::SUPPRESSION_MESSAGE));
943 }
944
945 #[test]
946 fn test_render_file_change_includes_summary_and_hunk_header() {
947 let renderer = DiffChatRenderer::new(true, 3, false);
948 let output = renderer.render_file_change(Path::new("file.rs"), "a\nb\n", "a\nc\n");
949
950 assert!(output.contains("▸ Edit file.rs (+1 -1)"));
951 assert!(output.contains("@@ -1 +1 @@"));
952 }
953
954 #[test]
955 fn test_render_file_change_keeps_blank_added_line_spacing() {
956 let renderer = DiffChatRenderer::new(false, 3, false);
957 let output = renderer.render_file_change(
958 Path::new("Cargo.toml"),
959 "[package]\n[dependencies]\n",
960 "[package]\n[dependencies]\n\n[workspace]\n",
961 );
962
963 assert!(output.contains("\n+ \n+ [workspace]\n"));
964 }
965}