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}