Skip to main content

vtcode_core/ui/
diff_renderer.rs

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        // Use consolidated DiffColorPalette with standard ANSI colors (no bold)
31        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            }, // For summary bullets
42            label: Style::new(), // For summary labels
43            path: Style::new(),  // For file paths
44            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            }, // For line numbers
59        }
60    }
61
62    /// Create palette from Git config colors
63    fn from_git_config(config: &GitColorConfig, use_colors: bool) -> Self {
64        if !use_colors {
65            return Self::new(false);
66        }
67
68        // Use consolidated DiffColorPalette - ignore git config theme for consistency
69        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/// Result of checking whether diffs should be suppressed
120#[derive(Debug, Clone)]
121pub struct DiffSuppressionCheck {
122    /// Whether diffs should be suppressed
123    pub should_suppress: bool,
124    /// Reason for suppression (if applicable)
125    pub reason: Option<String>,
126    /// Total number of files with changes
127    pub file_count: usize,
128    /// Total number of diff lines across all files
129    pub total_lines: usize,
130    /// Total additions across all files
131    pub total_additions: usize,
132    /// Total deletions across all files
133    pub total_deletions: usize,
134    /// List of changed files with their individual stats (path, additions, deletions)
135    pub file_stats: Vec<FileChangeStats>,
136}
137
138/// Statistics for a single changed file
139#[derive(Debug, Clone)]
140pub struct FileChangeStats {
141    pub path: String,
142    pub additions: usize,
143    pub deletions: usize,
144}
145
146/// Cached diff entry to avoid recomputation
147#[derive(Debug)]
148struct DiffCacheEntry {
149    diff: FileDiff,
150}
151
152/// Result of suppression check with optional cached diffs
153pub struct SuppressionResult {
154    pub check: DiffSuppressionCheck,
155    /// Cached diffs if not suppressed (avoids recomputation)
156    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    /// Create a new check indicating no suppression needed
177    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    /// Create a new check indicating suppression is needed
196    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    // Pre-rendered ANSI codes for performance (cached)
223    cached_styles: CachedStyles,
224}
225
226/// Pre-rendered ANSI escape codes to avoid repeated calls to style.render()
227struct 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    /// Create renderer with colors from Git config
290    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        // Pre-allocate buffer: header (~100 chars) + lines (~80 chars each)
309        let estimated_size = 100 + diff.lines.len() * 80;
310        let mut output = String::with_capacity(estimated_size);
311
312        // File header with edit indicator
313        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        // Pre-allocate: bullet(4) + label(4) + path + stats + resets + separators
334        let estimated_size = 50 + diff.file_path.len();
335        let mut output = String::with_capacity(estimated_size);
336
337        // Bullet: "▸" (3 bytes UTF-8)
338        output.push_str(&self.cached_styles.bullet);
339        output.push('▸');
340        output.push_str(&self.cached_styles.reset);
341        output.push(' ');
342
343        // Label: "Edit"
344        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        // Path
350        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        // Opening paren for stats
356        output.push('(');
357
358        // Additions
359        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        // Deletions
367        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    /// Render line directly into buffer to avoid allocation
377    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                // Format line number right-aligned in 4 chars without heap allocation
393                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            // CRITICAL: Apply style and reset without including newlines in the styled block
431            // This ensures Reset appears before any line terminators, preventing color bleed
432            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    /// Create renderer with colors from Git config
509    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        // Check suppression and get cached diffs if not suppressed
541        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        // Pre-allocate output buffer with estimated size
548        let estimated_size = changes.len() * 512; // Rough estimate per file
549        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        // Use cached diffs to avoid recomputation
560        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    /// Check if diffs should be suppressed based on size/count thresholds
570    /// Returns cached diffs if not suppressed to avoid recomputation
571    fn check_suppression_with_cache(
572        &self,
573        changes: &[(String, String, String)],
574    ) -> SuppressionResult {
575        let file_count = changes.len();
576
577        // Early termination: check file count first (cheapest check)
578        if file_count > diff_constants::MAX_INLINE_DIFF_FILES {
579            // Still need to compute stats for summary, but can use lightweight estimation
580            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, // Lines not computed for performance
590                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            // Check thresholds with early termination
619            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            // Cache diff for potential reuse
635            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    /// Lightweight stats estimation without full diff generation
662    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            // Estimate changes by line count difference (much faster than full diff)
672            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    /// Public API for checking suppression (without cache access)
694    pub fn check_suppression(&self, changes: &[(String, String, String)]) -> DiffSuppressionCheck {
695        self.check_suppression_with_cache(changes).check
696    }
697
698    /// Render a summary when diffs are suppressed
699    pub fn render_suppressed_summary(&self, check: &DiffSuppressionCheck) -> String {
700        // Pre-estimate size: header + summary + file list with colors
701        let estimated_size = 256 + (check.file_stats.len() * 100);
702        let mut output = String::with_capacity(estimated_size);
703
704        // Header with warning indicator
705        output.push_str("\n[WARN] ");
706        output.push_str(diff_constants::SUPPRESSION_MESSAGE);
707        output.push_str("\n\n");
708
709        // Overall summary with colored stats
710        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            // Additions stat
718            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            // Deletions stat
730            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        // List changed files with individual stats
742        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                    // Additions
758                    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                    // Deletions
770                    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        // Show reason in dimmed text
789        if let Some(reason) = &check.reason {
790            let _ = writeln!(output, "\nReason: {}", reason);
791        }
792
793        // Tip for viewing full diff
794        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        // Create more files than MAX_INLINE_DIFF_FILES
856        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        // Create a file with many changes
876        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")); // additions
908        assert!(output.contains("30")); // deletions
909        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        // Create enough changes to trigger suppression
919        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}