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::{DiffEntry, DiffResult};
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<Vec<DiffEntry>>` - Selected entries for application, 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.len());
256/// ```
257pub fn show_interactive(result: &DiffResult, color: bool) -> AppResult<Vec<DiffEntry>> {
258    let mut selected = Vec::with_capacity(result.total_changes());
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        for (idx, entry) in file.entries.iter().enumerate() {
278            if color {
279                println!(
280                    "{} {}",
281                    format!("[{}/{}]", idx + 1, file.entries.len()).yellow(),
282                    entry.analyzer.green()
283                );
284                println!("{}", format!("Line {}:", entry.line).dimmed());
285                println!("{}", format!("- {}", entry.original).red());
286
287                if let Some(import) = &entry.import {
288                    println!("{}", format!("+ {}", import).green());
289                }
290
291                println!("{}", format!("+ {}", entry.modified).green());
292            } else {
293                println!("[{}/{}] {}", idx + 1, file.entries.len(), entry.analyzer);
294                println!("Line {}:", entry.line);
295                println!("- {}", entry.original);
296
297                if let Some(import) = &entry.import {
298                    println!("+ {}", import);
299                }
300
301                println!("+ {}", entry.modified);
302            }
303            println!();
304
305            if apply_all {
306                selected.push(entry.clone());
307                continue;
308            }
309
310            print!("{}", "Apply this fix? [y/n/a/q]: ".bold());
311            io::stdout().flush().map_err(IoError::from)?;
312
313            let mut input = String::new();
314            io::stdin().read_line(&mut input).map_err(IoError::from)?;
315
316            match input.trim().to_lowercase().as_str() {
317                "y" | "yes" => {
318                    selected.push(entry.clone());
319                    println!("{}", "Applied".green());
320                }
321                "n" | "no" => {
322                    println!("{}", "Skipped".yellow());
323                }
324                "a" | "all" => {
325                    apply_all = true;
326                    selected.push(entry.clone());
327                    println!("{}", "Applying all remaining changes".green().bold());
328                }
329                "q" | "quit" => {
330                    println!("{}", "Quit".red());
331                    break;
332                }
333                _ => {
334                    println!("{}", "Invalid input, skipping".red());
335                }
336            }
337            println!();
338        }
339    }
340
341    println!(
342        "\n{}",
343        format!("Selected {} changes for application", selected.len())
344            .yellow()
345            .bold()
346    );
347
348    Ok(selected)
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::differ::types::FileDiff;
355
356    #[test]
357    fn test_show_summary_empty() {
358        let result = DiffResult::new();
359        show_summary(&result, false);
360    }
361
362    #[test]
363    fn test_show_full_empty() {
364        let result = DiffResult::new();
365        show_full(&result, false);
366    }
367
368    #[test]
369    fn test_show_summary_with_data() {
370        let mut result = DiffResult::new();
371        let mut file = FileDiff::new("test.rs".to_string());
372
373        file.add_entry(DiffEntry {
374            line:        1,
375            analyzer:    "test".to_string(),
376            original:    "old".to_string(),
377            modified:    "new".to_string(),
378            description: "desc".to_string(),
379            import:      None
380        });
381
382        result.add_file(file);
383        show_summary(&result, false);
384    }
385
386    #[test]
387    fn test_show_full_with_data() {
388        let mut result = DiffResult::new();
389        let mut file = FileDiff::new("test.rs".to_string());
390
391        file.add_entry(DiffEntry {
392            line:        10,
393            analyzer:    "test".to_string(),
394            original:    "old".to_string(),
395            modified:    "new".to_string(),
396            description: "desc".to_string(),
397            import:      None
398        });
399
400        result.add_file(file);
401        show_full(&result, false);
402    }
403}