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}