Skip to main content

vtcode_core/ui/
diff_renderer.rs

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        // Use consolidated DiffColorPalette with standard ANSI colors (no bold)
37        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            }, // For summary bullets
48            label: Style::new(), // For summary labels
49            path: Style::new(),  // For file paths
50            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            }, // For line numbers
65        }
66    }
67
68    /// Create palette from Git config colors
69    fn from_git_config(config: &GitColorConfig, use_colors: bool) -> Self {
70        if !use_colors {
71            return Self::new(false);
72        }
73
74        // Use consolidated DiffColorPalette - ignore git config theme for consistency
75        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/// A single line within a unified diff output.
96#[derive(Debug, Clone)]
97pub struct DiffLine {
98    /// Whether this line is an addition, removal, context, or header.
99    pub line_type: DiffLineType,
100    /// The text content of the diff line (without the leading `+`/`-`/` ` prefix).
101    pub content: String,
102    /// Line number in the old file, if available.
103    pub line_number_old: Option<u32>,
104    /// Line number in the new file, if available.
105    pub line_number_new: Option<u32>,
106}
107
108/// The kind of a single diff line.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum DiffLineType {
111    /// A line added in the new file.
112    Added,
113    /// A line removed from the old file.
114    Removed,
115    /// An unchanged context line present in both files.
116    Context,
117    /// A hunk header line (e.g. `@@ -1 +1 @@`).
118    Header,
119}
120
121/// Aggregated diff result for a single file.
122#[derive(Debug)]
123pub struct FileDiff {
124    /// Relative or absolute path of the changed file.
125    pub file_path: String,
126    /// Ordered sequence of diff lines including hunk headers.
127    pub lines: Vec<DiffLine>,
128    /// Summary statistics for this file.
129    pub stats: DiffStats,
130}
131
132/// Counters for additions, deletions, and total changes in a diff.
133#[derive(Debug, Clone)]
134pub struct DiffStats {
135    /// Number of added lines.
136    pub additions: usize,
137    /// Number of removed lines.
138    pub deletions: usize,
139    /// Total number of changed lines (additions + deletions).
140    pub changes: usize,
141}
142
143/// Result of checking whether diffs should be suppressed
144#[derive(Debug, Clone)]
145pub struct DiffSuppressionCheck {
146    /// Whether diffs should be suppressed
147    pub should_suppress: bool,
148    /// Reason for suppression (if applicable)
149    pub reason: Option<String>,
150    /// Total number of files with changes
151    pub file_count: usize,
152    /// Total number of diff lines across all files
153    pub total_lines: usize,
154    /// Total additions across all files
155    pub total_additions: usize,
156    /// Total deletions across all files
157    pub total_deletions: usize,
158    /// List of changed files with their individual stats (path, additions, deletions)
159    pub file_stats: Vec<FileChangeStats>,
160}
161
162/// Statistics for a single changed file.
163#[derive(Debug, Clone)]
164pub struct FileChangeStats {
165    /// File path relative to the repository root.
166    pub path: String,
167    /// Number of added lines in this file.
168    pub additions: usize,
169    /// Number of removed lines in this file.
170    pub deletions: usize,
171}
172
173/// Result of a suppression check.
174pub struct SuppressionResult {
175    /// The suppression check result with statistics.
176    pub check: DiffSuppressionCheck,
177}
178
179impl SuppressionResult {
180    fn suppressed(check: DiffSuppressionCheck) -> Self {
181        Self { check }
182    }
183}
184
185impl DiffSuppressionCheck {
186    /// Create a new check indicating no suppression needed
187    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    /// Create a new check indicating suppression is needed
206    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
226/// Renders unified diffs with ANSI-colored output and optional line numbers.
227pub struct DiffRenderer {
228    show_line_numbers: bool,
229    context_lines: usize,
230    use_colors: bool,
231    cached_styles: CachedStyles,
232}
233
234/// Named options for [`DiffRenderer`] and [`DiffChatRenderer`].
235///
236/// Stable-Rust emulation of named/optional arguments: call sites use named
237/// fields with struct-update defaults instead of positional
238/// `(show_line_numbers, context_lines, use_colors)`, avoiding bool-trap
239/// swaps and making `context_lines` omission extensible.
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub struct DiffRendererOptions {
242    /// Prefix rows with old/new line numbers.
243    pub show_line_numbers: bool,
244    /// Unchanged lines retained around each change.
245    pub context_lines: usize,
246    /// Emit ANSI styles (false keeps output plain/deterministic).
247    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
260/// Pre-rendered ANSI escape codes to avoid repeated calls to style.render()
261struct 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    /// Create a new renderer with default ANSI color palette.
312    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    /// Create a renderer from named options.
317    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    /// Create renderer with colors from Git config
329    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    /// Create renderer with colors from Git config and named options.
339    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    /// Render a single file diff to an ANSI-formatted string.
351    pub fn render_diff(&self, diff: &FileDiff) -> String {
352        // Pre-allocate buffer: header (~100 chars) + lines (~80 chars each)
353        let estimated_size = 100 + diff.lines.len() * 80;
354        let mut output = String::with_capacity(estimated_size);
355
356        // File header with edit indicator
357        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        // Pre-allocate: bullet(4) + label(4) + path + stats + resets + separators
406        let estimated_size = 50 + diff.file_path.len();
407        let mut output = String::with_capacity(estimated_size);
408
409        // Bullet: "▸" (3 bytes UTF-8)
410        output.push_str(&self.cached_styles.bullet);
411        output.push('▸');
412        output.push_str(&self.cached_styles.reset);
413        output.push(' ');
414
415        // Label: "Edit"
416        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        // Path
422        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        // Opening paren for stats
428        output.push('(');
429
430        // Additions
431        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        // Deletions
439        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    /// Render line directly into buffer to avoid allocation
449    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                // Format line number right-aligned in 4 chars without heap allocation
463                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    /// Compute a diff between two file contents and return the structured result.
498    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
552/// High-level renderer that wraps [`DiffRenderer`] with bounded previews while
553/// retaining legacy suppression diagnostics for compatibility callers.
554pub struct DiffChatRenderer {
555    diff_renderer: DiffRenderer,
556}
557
558impl DiffChatRenderer {
559    /// Create a new chat renderer with default ANSI color palette.
560    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    /// Create a chat renderer from named options.
565    pub fn with_options(options: DiffRendererOptions) -> Self {
566        Self { diff_renderer: DiffRenderer::with_options(options) }
567    }
568
569    /// Create renderer with colors from Git config
570    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    /// Create renderer with colors from Git config and named options.
580    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    /// Render a single file change (old vs new content) to an ANSI string.
587    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    /// Render multiple file changes with bounded per-file excerpts.
595    pub fn render_multiple_changes(&self, changes: Vec<(String, String, String)>) -> String {
596        // Keep every file recognizable while bounding body rows across the
597        // complete multi-file preview. The legacy suppression APIs remain
598        // available to callers that need their compatibility diagnostics.
599        let estimated_size = changes.len() * 512; // Rough estimate per file
600        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    /// Check if diffs should be suppressed based on size/count thresholds.
618    fn check_suppression_with_cache(&self, changes: &[(String, String, String)]) -> SuppressionResult {
619        let file_count = changes.len();
620
621        // Early termination: check file count first (cheapest check)
622        if file_count > diff_constants::MAX_INLINE_DIFF_FILES {
623            // Still need to compute stats for summary, but can use lightweight estimation
624            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, // Lines not computed for performance
629                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            // Check thresholds with early termination
655            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    /// Lightweight stats estimation without full diff generation
694    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            // Estimate changes by line count difference (much faster than full diff)
701            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    /// Public API for checking suppression (without cache access)
719    pub fn check_suppression(&self, changes: &[(String, String, String)]) -> DiffSuppressionCheck {
720        self.check_suppression_with_cache(changes).check
721    }
722
723    /// Render a summary when diffs are suppressed
724    pub fn render_suppressed_summary(&self, check: &DiffSuppressionCheck) -> String {
725        // Pre-estimate size: header + summary + file list with colors
726        let estimated_size = 256 + (check.file_stats.len() * 100);
727        let mut output = String::with_capacity(estimated_size);
728
729        // Header with warning indicator
730        output.push_str("\n[WARN] ");
731        output.push_str(diff_constants::SUPPRESSION_MESSAGE);
732        output.push_str("\n\n");
733
734        // Overall summary with colored stats
735        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            // Additions stat
743            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            // Deletions stat
759            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        // List changed files with individual stats
775        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                    // Additions
791                    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                    // Deletions
803                    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        // Show reason in dimmed text
822        if let Some(reason) = &check.reason {
823            let _ = writeln!(output, "\nReason: {reason}");
824        }
825
826        // Tip for viewing full diff
827        output.push('\n');
828        output.push_str(diff_constants::SUPPRESSION_HINT);
829        output.push('\n');
830
831        output
832    }
833
834    /// Render a brief operation summary line (success or failure indicator).
835    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
868/// Generate a standard unified diff string between two file contents.
869pub 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        // Create more files than MAX_INLINE_DIFF_FILES
933        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        // Create a file with many changes
949        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")); // additions
980        assert!(output.contains("30")); // deletions
981        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}