Skip to main content

cargo_quality/differ/
display.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Professional responsive diff display with grid layout.
5//!
6//! This module provides a sophisticated diff visualization system that adapts
7//! to terminal width, offering newspaper-style column layouts for optimal
8//! screen space utilization. Features include intelligent import grouping,
9//! ANSI-aware text measurement, and zero-allocation rendering paths.
10//!
11//! # Architecture
12//!
13//! The display system is organized into specialized modules:
14//!
15//! - `types` - Core data structures for rendered output
16//! - `formatting` - Text padding and width calculation
17//! - `grouping` - Import deduplication and intelligent grouping
18//! - `grid` - Responsive column layout calculations
19//! - `render` - File diff block rendering
20//!
21//! # Performance
22//!
23//! - Pre-allocated vectors with estimated capacities
24//! - Single-pass width calculations
25//! - ANSI-aware measurements using `console` crate
26//! - Minimal string allocations
27//! - Zero-cost abstractions for layout logic
28//!
29//! # Examples
30//!
31//! ```no_run
32//! use cargo_quality::differ::{DiffResult, display::show_full};
33//!
34//! let result = DiffResult::new();
35//! show_full(&result, false);
36//! ```
37
38pub mod formatting;
39pub mod grid;
40pub mod grouping;
41pub mod render;
42pub mod types;
43
44// Re-export key types and functions for public API
45use std::{
46    collections::HashMap,
47    io::{self, Write}
48};
49
50use masterror::AppResult;
51use owo_colors::OwoColorize;
52use terminal_size::{Width, terminal_size};
53
54pub use self::{
55    grid::{calculate_columns, render_grid},
56    render::render_file_block
57};
58use super::types::{DiffResult, FileDiff};
59use crate::error::IoError;
60
61/// Displays diff in summary mode with brief statistics.
62///
63/// Shows a compact overview of changes grouped by file and analyzer,
64/// providing quick insight into the scope of modifications without
65/// showing detailed line-by-line changes.
66///
67/// # Output Format
68///
69/// ```text
70/// DIFF SUMMARY
71///
72/// file1.rs:
73///   analyzer1: 3 issues
74///   analyzer2: 1 issue
75///
76/// file2.rs:
77///   analyzer1: 2 issues
78///
79/// Total: 6 changes in 2 files
80/// ```
81///
82/// # Arguments
83///
84/// * `result` - Diff results to display
85///
86/// # Examples
87///
88/// ```no_run
89/// use cargo_quality::differ::{DiffResult, display::show_summary};
90///
91/// let result = DiffResult::new();
92/// show_summary(&result, false);
93/// ```
94pub fn show_summary(result: &DiffResult, color: bool) {
95    if color {
96        println!("\n{}\n", "DIFF SUMMARY".bold());
97    } else {
98        println!("\nDIFF SUMMARY\n");
99    }
100
101    for file in &result.files {
102        if color {
103            println!("{}:", file.path.cyan().bold());
104        } else {
105            println!("{}:", file.path);
106        }
107
108        let mut analyzer_counts = HashMap::new();
109        for entry in &file.entries {
110            *analyzer_counts.entry(&entry.analyzer).or_insert(0) += 1;
111        }
112
113        for (analyzer, count) in analyzer_counts {
114            if color {
115                println!(
116                    "  {}: {} {}",
117                    analyzer.green(),
118                    count,
119                    if count == 1 { "issue" } else { "issues" }
120                );
121            } else {
122                println!(
123                    "  {}: {} {}",
124                    analyzer,
125                    count,
126                    if count == 1 { "issue" } else { "issues" }
127                );
128            }
129        }
130        println!();
131    }
132
133    let summary = format!(
134        "Total: {} changes in {} files",
135        result.total_changes(),
136        result.total_files()
137    );
138
139    if color {
140        println!("{}", summary.yellow().bold());
141    } else {
142        println!("{}", summary);
143    }
144}
145
146/// Displays full responsive diff output with adaptive grid layout.
147///
148/// Automatically arranges file diffs in newspaper-style columns based on
149/// terminal width. On narrow terminals, displays one file per row. On wider
150/// terminals, arranges multiple files side-by-side for efficient space usage.
151///
152/// # Layout Modes
153///
154/// - **Narrow** (< 100 chars): Single column, vertical stacking
155/// - **Medium** (100-200 chars): 2 columns side-by-side
156/// - **Wide** (> 200 chars): 3+ columns based on content width
157///
158/// # Arguments
159///
160/// * `result` - Diff results to display
161///
162/// # Performance
163///
164/// - Pre-renders all files once
165/// - Calculates optimal column count based on terminal width
166/// - Uses ANSI-aware padding for perfect alignment
167/// - Minimal allocations during grid rendering
168///
169/// # Examples
170///
171/// ```no_run
172/// use cargo_quality::differ::{DiffResult, display::show_full};
173///
174/// let result = DiffResult::new();
175/// show_full(&result, false);
176/// ```
177pub fn show_full(result: &DiffResult, color: bool) {
178    if color {
179        println!("\n{}\n", "DIFF OUTPUT".bold());
180    } else {
181        println!("\nDIFF OUTPUT\n");
182    }
183
184    let term_width = terminal_size()
185        .map(|(Width(w), _)| w as usize)
186        .unwrap_or(80);
187
188    let rendered: Vec<_> = result
189        .files
190        .iter()
191        .map(|f| render_file_block(f, color))
192        .collect();
193
194    let columns = calculate_columns(&rendered, term_width);
195
196    if columns > 1 {
197        let layout_info = format!(
198            "Layout: {} columns (terminal width: {})",
199            columns, term_width
200        );
201
202        if color {
203            println!("{}\n", layout_info.dimmed());
204        } else {
205            println!("{}\n", layout_info);
206        }
207    }
208
209    render_grid(&rendered, columns);
210
211    let summary = format!(
212        "Total: {} changes in {} files",
213        result.total_changes(),
214        result.total_files()
215    );
216
217    if color {
218        println!("{}", summary.yellow().bold());
219    } else {
220        println!("{}", summary);
221    }
222}
223
224/// Displays interactive diff with user prompts for selective application.
225///
226/// Presents each change individually and asks for user confirmation before
227/// applying. Supports batch operations (apply all, quit) for efficiency.
228///
229/// # Commands
230///
231/// - `y` / `yes` - Apply this change
232/// - `n` / `no` - Skip this change
233/// - `a` / `all` - Apply all remaining changes
234/// - `q` / `quit` - Exit without processing remaining changes
235///
236/// # Arguments
237///
238/// * `result` - Diff results to display
239///
240/// # Returns
241///
242/// `AppResult<DiffResult>` - Selected entries grouped by file, or error
243///
244/// # Errors
245///
246/// Returns error if I/O operations fail during user input reading.
247///
248/// # Examples
249///
250/// ```no_run
251/// use cargo_quality::differ::{DiffResult, display::show_interactive};
252///
253/// let result = DiffResult::new();
254/// let selected = show_interactive(&result, false).unwrap();
255/// println!("Selected {} changes", selected.total_changes());
256/// ```
257pub fn show_interactive(result: &DiffResult, color: bool) -> AppResult<DiffResult> {
258    let mut selected = DiffResult::new();
259    let mut apply_all = false;
260
261    if color {
262        println!("\n{}\n", "INTERACTIVE DIFF".bold());
263        println!("{}", "Commands: y=yes, n=no, a=all, q=quit\n".dimmed());
264    } else {
265        println!("\nINTERACTIVE DIFF\n");
266        println!("Commands: y=yes, n=no, a=all, q=quit\n");
267    }
268
269    for file in &result.files {
270        if color {
271            println!("{}", format!("File: {}", file.path).cyan().bold());
272        } else {
273            println!("File: {}", file.path);
274        }
275        println!();
276
277        let mut file_selected = FileDiff::new(file.path.clone());
278
279        for (idx, entry) in file.entries.iter().enumerate() {
280            if color {
281                println!(
282                    "{} {}",
283                    format!("[{}/{}]", idx + 1, file.entries.len()).yellow(),
284                    entry.analyzer.green()
285                );
286                println!("{}", format!("Line {}:", entry.line).dimmed());
287                println!("{}", format!("- {}", entry.original).red());
288
289                if let Some(import) = &entry.import {
290                    println!("{}", format!("+ {}", import).green());
291                }
292
293                println!("{}", format!("+ {}", entry.modified).green());
294            } else {
295                println!("[{}/{}] {}", idx + 1, file.entries.len(), entry.analyzer);
296                println!("Line {}:", entry.line);
297                println!("- {}", entry.original);
298
299                if let Some(import) = &entry.import {
300                    println!("+ {}", import);
301                }
302
303                println!("+ {}", entry.modified);
304            }
305            println!();
306
307            if apply_all {
308                file_selected.add_entry(entry.clone());
309                continue;
310            }
311
312            print!("{}", "Apply this fix? [y/n/a/q]: ".bold());
313            io::stdout().flush().map_err(IoError::from)?;
314
315            let mut input = String::new();
316            io::stdin().read_line(&mut input).map_err(IoError::from)?;
317
318            match input.trim().to_lowercase().as_str() {
319                "y" | "yes" => {
320                    file_selected.add_entry(entry.clone());
321                    println!("{}", "Applied".green());
322                }
323                "n" | "no" => {
324                    println!("{}", "Skipped".yellow());
325                }
326                "a" | "all" => {
327                    apply_all = true;
328                    file_selected.add_entry(entry.clone());
329                    println!("{}", "Applying all remaining changes".green().bold());
330                }
331                "q" | "quit" => {
332                    println!("{}", "Quit".red());
333                    selected.add_file(file_selected);
334                    println!(
335                        "\n{}",
336                        format!(
337                            "Selected {} changes for application",
338                            selected.total_changes()
339                        )
340                        .yellow()
341                        .bold()
342                    );
343                    return Ok(selected);
344                }
345                _ => {
346                    println!("{}", "Invalid input, skipping".red());
347                }
348            }
349            println!();
350        }
351
352        selected.add_file(file_selected);
353    }
354
355    println!(
356        "\n{}",
357        format!(
358            "Selected {} changes for application",
359            selected.total_changes()
360        )
361        .yellow()
362        .bold()
363    );
364
365    Ok(selected)
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371    use crate::{analyzer::TextEdit, differ::types::DiffEntry};
372
373    #[test]
374    fn test_show_summary_empty() {
375        let result = DiffResult::new();
376        show_summary(&result, false);
377    }
378
379    #[test]
380    fn test_show_full_empty() {
381        let result = DiffResult::new();
382        show_full(&result, false);
383    }
384
385    #[test]
386    fn test_show_summary_with_data() {
387        let mut result = DiffResult::new();
388        let mut file = FileDiff::new("test.rs".to_string());
389
390        file.add_entry(DiffEntry {
391            line:        1,
392            analyzer:    "test".to_string(),
393            original:    "old".to_string(),
394            modified:    "new".to_string(),
395            description: "desc".to_string(),
396            import:      None,
397            edit:        TextEdit::default()
398        });
399
400        result.add_file(file);
401        show_summary(&result, false);
402    }
403
404    #[test]
405    fn test_show_full_with_data() {
406        let mut result = DiffResult::new();
407        let mut file = FileDiff::new("test.rs".to_string());
408
409        file.add_entry(DiffEntry {
410            line:        10,
411            analyzer:    "test".to_string(),
412            original:    "old".to_string(),
413            modified:    "new".to_string(),
414            description: "desc".to_string(),
415            import:      None,
416            edit:        TextEdit::default()
417        });
418
419        result.add_file(file);
420        show_full(&result, false);
421    }
422}