Skip to main content

cargo_quality/differ/display/
render.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use console::measure_text_width;
5use owo_colors::OwoColorize;
6
7use super::{grid::MIN_FILE_WIDTH, grouping::group_imports, types::RenderedFile};
8use crate::differ::types::FileDiff;
9
10/// Estimated lines per file diff for pre-allocation.
11///
12/// Based on typical diff structure:
13/// - 2 lines for header
14/// - 1-5 lines for imports
15/// - 3-5 lines per issue (analyzer header + line + old + new + blank)
16const ESTIMATED_LINES_PER_FILE: usize = 20;
17
18/// Renders a single file diff into formatted output lines.
19///
20/// Transforms file diff data into visual representation with colors,
21/// separators, and grouped imports. Pre-calculates visual width for grid layout
22/// optimization.
23///
24/// # Structure
25///
26/// ```text
27/// File: path/to/file.rs           <- Header (cyan + bold)
28/// ────────────────────────────    <- Separator
29/// Imports (file top)              <- Import section header
30/// +    use std::fs::write;        <- Grouped imports (green)
31///
32/// analyzer_name (N issues)        <- Analyzer section (green + bold)
33///
34/// Line 42                         <- Line number (cyan)
35/// -    old code                   <- Removal (red)
36/// +    new code                   <- Addition (green)
37///
38/// ════════════════════════════    <- End separator
39/// ```
40///
41/// # Arguments
42///
43/// * `file` - File diff containing all changes
44///
45/// # Returns
46///
47/// `RenderedFile` with formatted lines and calculated width
48///
49/// # Performance
50///
51/// - Pre-allocates Vec with estimated capacity
52/// - Groups and deduplicates imports once
53/// - Calculates width incrementally (single pass)
54/// - Minimizes string allocations
55///
56/// # Examples
57///
58/// ```no_run
59/// use cargo_quality::differ::{display::render::render_file_block, types::FileDiff};
60///
61/// let file_diff = FileDiff::new("test.rs".to_string());
62/// let rendered = render_file_block(&file_diff, false);
63///
64/// assert!(!rendered.lines.is_empty());
65/// assert!(rendered.width >= 40);
66/// ```
67pub fn render_file_block(file: &FileDiff, color: bool) -> RenderedFile {
68    let estimated_capacity = ESTIMATED_LINES_PER_FILE + file.entries.len() * 5;
69
70    let mut lines = Vec::with_capacity(estimated_capacity);
71    let mut max_width = 0;
72
73    render_header(&mut lines, &mut max_width, &file.path, color);
74
75    render_imports(&mut lines, &mut max_width, file, color);
76
77    render_issues(&mut lines, &mut max_width, file, color);
78
79    render_empty_lines_note(&mut lines, max_width, file, color);
80
81    render_footer(&mut lines, &mut max_width, color);
82
83    RenderedFile {
84        lines,
85        width: max_width.max(MIN_FILE_WIDTH)
86    }
87}
88
89/// Renders file header with path.
90///
91/// # Arguments
92///
93/// * `lines` - Output buffer
94/// * `max_width` - Running maximum width tracker
95/// * `path` - File path string
96#[inline]
97fn render_header(lines: &mut Vec<String>, max_width: &mut usize, path: &str, color: bool) {
98    let header = format!("File: {}", path);
99    *max_width = (*max_width).max(measure_text_width(&header));
100
101    if color {
102        lines.push(header.cyan().bold().to_string());
103    } else {
104        lines.push(header);
105    }
106
107    let separator = "─".repeat(40);
108    *max_width = (*max_width).max(measure_text_width(&separator));
109
110    if color {
111        lines.push(separator.dimmed().to_string());
112    } else {
113        lines.push(separator);
114    }
115}
116
117/// Renders grouped import section if present.
118///
119/// # Arguments
120///
121/// * `lines` - Output buffer
122/// * `max_width` - Running maximum width tracker
123/// * `file` - File diff data
124#[inline]
125fn render_imports(lines: &mut Vec<String>, max_width: &mut usize, file: &FileDiff, color: bool) {
126    let imports: Vec<&str> = file
127        .entries
128        .iter()
129        .filter_map(|e| e.import.as_deref())
130        .collect();
131
132    if imports.is_empty() {
133        return;
134    }
135
136    let import_header = "Imports (file top)";
137    *max_width = (*max_width).max(measure_text_width(import_header));
138
139    if color {
140        lines.push(import_header.dimmed().to_string());
141    } else {
142        lines.push(import_header.to_string());
143    }
144
145    let grouped = group_imports(&imports);
146    for import in grouped {
147        let import_line = format!("+    {}", import);
148        *max_width = (*max_width).max(measure_text_width(&import_line));
149
150        if color {
151            lines.push(import_line.green().to_string());
152        } else {
153            lines.push(import_line);
154        }
155    }
156
157    lines.push(String::new());
158}
159
160/// Renders all issues grouped by analyzer.
161///
162/// # Arguments
163///
164/// * `lines` - Output buffer
165/// * `max_width` - Running maximum width tracker
166/// * `file` - File diff data
167#[inline]
168fn render_issues(lines: &mut Vec<String>, max_width: &mut usize, file: &FileDiff, color: bool) {
169    let mut last_analyzer = "";
170
171    for entry in &file.entries {
172        if entry.analyzer == "empty_lines" {
173            continue;
174        }
175
176        if entry.analyzer != last_analyzer {
177            if !last_analyzer.is_empty() {
178                lines.push(String::new());
179            }
180
181            let analyzer_line = format!(
182                "{} ({} issues)",
183                entry.analyzer,
184                file.entries
185                    .iter()
186                    .filter(|e| e.analyzer == entry.analyzer)
187                    .count()
188            );
189
190            *max_width = (*max_width).max(measure_text_width(&analyzer_line));
191
192            if color {
193                lines.push(analyzer_line.green().bold().to_string());
194            } else {
195                lines.push(analyzer_line);
196            }
197
198            lines.push(String::new());
199
200            last_analyzer = &entry.analyzer;
201        }
202
203        render_issue_entry(lines, max_width, entry, color);
204    }
205}
206
207/// Renders single issue entry with line changes.
208///
209/// # Arguments
210///
211/// * `lines` - Output buffer
212/// * `max_width` - Running maximum width tracker
213/// * `entry` - Diff entry data
214#[inline]
215fn render_issue_entry(
216    lines: &mut Vec<String>,
217    max_width: &mut usize,
218    entry: &crate::differ::types::DiffEntry,
219    color: bool
220) {
221    let line_header = format!("Line {}", entry.line);
222    *max_width = (*max_width).max(measure_text_width(&line_header));
223
224    if color {
225        lines.push(line_header.cyan().to_string());
226    } else {
227        lines.push(line_header);
228    }
229
230    let old_line = format!("-    {}", entry.original);
231    *max_width = (*max_width).max(measure_text_width(&old_line));
232
233    if color {
234        lines.push(old_line.red().to_string());
235    } else {
236        lines.push(old_line);
237    }
238
239    let new_line = format!("+    {}", entry.modified);
240    *max_width = (*max_width).max(measure_text_width(&new_line));
241
242    if color {
243        lines.push(new_line.green().to_string());
244    } else {
245        lines.push(new_line);
246    }
247
248    lines.push(String::new());
249}
250
251/// Renders empty lines removal note if present.
252///
253/// # Arguments
254///
255/// * `lines` - Output buffer
256/// * `max_width` - Maximum width from other content (not updated)
257/// * `file` - File diff data
258#[inline]
259fn render_empty_lines_note(
260    lines: &mut Vec<String>,
261    max_width: usize,
262    file: &FileDiff,
263    color: bool
264) {
265    let empty_entries: Vec<_> = file
266        .entries
267        .iter()
268        .filter(|e| e.analyzer == "empty_lines")
269        .collect();
270
271    if empty_entries.is_empty() {
272        return;
273    }
274
275    let line_numbers: Vec<String> = empty_entries.iter().map(|e| e.line.to_string()).collect();
276
277    let prefix = format!(
278        "Note: {} empty {} will be removed from lines: ",
279        empty_entries.len(),
280        if empty_entries.len() == 1 {
281            "line"
282        } else {
283            "lines"
284        }
285    );
286
287    let mut current_line = prefix.clone();
288
289    for (i, num) in line_numbers.iter().enumerate() {
290        let separator = if i == 0 { "" } else { ", " };
291        let addition = format!("{}{}", separator, num);
292
293        if current_line.len() + addition.len() > max_width && i > 0 {
294            if color {
295                lines.push(current_line.dimmed().italic().to_string());
296            } else {
297                lines.push(current_line);
298            }
299            current_line = format!(" {}", num);
300        } else {
301            current_line.push_str(&addition);
302        }
303    }
304
305    if !current_line.is_empty() {
306        if color {
307            lines.push(current_line.dimmed().italic().to_string());
308        } else {
309            lines.push(current_line);
310        }
311    }
312
313    lines.push(String::new());
314}
315
316/// Renders footer separator.
317///
318/// # Arguments
319///
320/// * `lines` - Output buffer
321/// * `max_width` - Running maximum width tracker
322#[inline]
323fn render_footer(lines: &mut Vec<String>, max_width: &mut usize, color: bool) {
324    let end_separator = "═".repeat(40);
325    *max_width = (*max_width).max(measure_text_width(&end_separator));
326
327    if color {
328        lines.push(end_separator.dimmed().to_string());
329    } else {
330        lines.push(end_separator);
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use crate::differ::types::{DiffEntry, FileDiff};
338
339    #[test]
340    fn test_render_file_block_empty() {
341        let file = FileDiff::new("test.rs".to_string());
342        let rendered = render_file_block(&file, false);
343
344        assert!(!rendered.lines.is_empty());
345        assert!(rendered.width >= MIN_FILE_WIDTH);
346    }
347
348    #[test]
349    fn test_render_file_block_with_entry() {
350        let mut file = FileDiff::new("test.rs".to_string());
351        file.add_entry(DiffEntry {
352            line:        10,
353            analyzer:    "test".to_string(),
354            original:    "old".to_string(),
355            modified:    "new".to_string(),
356            description: "desc".to_string(),
357            import:      None
358        });
359
360        let rendered = render_file_block(&file, false);
361        assert!(rendered.line_count() > 5);
362    }
363
364    #[test]
365    fn test_render_file_block_with_import() {
366        let mut file = FileDiff::new("test.rs".to_string());
367        file.add_entry(DiffEntry {
368            line:        10,
369            analyzer:    "path_import".to_string(),
370            original:    "std::fs::read()".to_string(),
371            modified:    "read()".to_string(),
372            description: "Use import".to_string(),
373            import:      Some("use std::fs::read;".to_string())
374        });
375
376        let rendered = render_file_block(&file, false);
377        assert!(rendered.lines.iter().any(|l| l.contains("Imports")));
378    }
379
380    #[test]
381    fn test_render_file_block_multiple_analyzers() {
382        let mut file = FileDiff::new("test.rs".to_string());
383
384        file.add_entry(DiffEntry {
385            line:        10,
386            analyzer:    "analyzer1".to_string(),
387            original:    "old1".to_string(),
388            modified:    "new1".to_string(),
389            description: "desc1".to_string(),
390            import:      None
391        });
392
393        file.add_entry(DiffEntry {
394            line:        20,
395            analyzer:    "analyzer2".to_string(),
396            original:    "old2".to_string(),
397            modified:    "new2".to_string(),
398            description: "desc2".to_string(),
399            import:      None
400        });
401
402        let rendered = render_file_block(&file, false);
403        assert!(rendered.lines.iter().any(|l| l.contains("analyzer1")));
404        assert!(rendered.lines.iter().any(|l| l.contains("analyzer2")));
405    }
406
407    #[test]
408    fn test_render_respects_capacity() {
409        let file = FileDiff::new("test.rs".to_string());
410        let rendered = render_file_block(&file, false);
411
412        assert!(rendered.lines.capacity() >= ESTIMATED_LINES_PER_FILE);
413    }
414}