morph_cli/core/diff/
renderer.rs1use colored::Colorize;
2use std::path::Path;
3
4use super::preview::{
5 ChangedFile, DiffHunk, DiffLine, FilePreview, LineType, PreviewConfig, TransformationReport,
6};
7
8#[allow(dead_code)]
9const CONTEXT_LINES: usize = 3;
10
11pub struct DiffRenderer {
12 config: PreviewConfig,
13}
14
15impl DiffRenderer {
16 pub fn new(config: PreviewConfig) -> Self {
17 Self { config }
18 }
19
20 pub fn render_file_preview(&self, preview: &FilePreview) {
21 println!();
22 self.render_file_header(&preview.path);
23 println!();
24
25 if preview.is_binary {
26 self.render_binary_notice();
27 return;
28 }
29
30 if preview.was_truncated {
31 self.render_truncation_notice(preview.line_count);
32 }
33
34 if self.config.summary_only {
35 return;
36 }
37
38 self.render_hunks(&preview.hunks);
39 }
40
41 fn render_file_header(&self, path: &Path) {
42 let display = path.display().to_string();
43 println!("{} {}", "diff".bold().cyan(), display.bold());
44 }
45
46 fn render_binary_notice(&self) {
47 println!(" {} binary file - skipped", "skip".bold().dimmed());
48 }
49
50 fn render_truncation_notice(&self, line_count: usize) {
51 let max_display = self.config.max_lines * 2;
52 println!(
53 " {} output truncated from {} lines",
54 "note".bold().yellow(),
55 line_count
56 );
57 println!(
58 " {} use --max-preview-lines={} to adjust",
59 "hint".bold().dimmed(),
60 max_display.saturating_add(100)
61 );
62 }
63
64 fn render_hunks(&self, hunks: &[DiffHunk]) {
65 for hunk in hunks {
66 self.render_hunk(hunk);
67 }
68 }
69
70 fn render_hunk(&self, hunk: &DiffHunk) {
71 println!(
72 "@@ -{},{} +{},{} @@",
73 hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
74 );
75
76 let visible_lines: Vec<&DiffLine> = hunk.lines.iter().take(self.config.max_lines).collect();
77 let mut has_more = false;
78
79 if self.config.max_lines > 0 && hunk.lines.len() > self.config.max_lines {
80 has_more = true;
81 }
82
83 for line in visible_lines {
84 self.render_line(line);
85 }
86
87 if has_more {
88 println!(
89 " {} {} more lines",
90 "note".bold().yellow(),
91 hunk.lines.len() - self.config.max_lines
92 );
93 }
94 }
95
96 fn render_line(&self, line: &DiffLine) {
97 let content = &line.content;
98 match line.line_type {
99 LineType::Addition => {
100 println!("{}", content.green());
101 }
102 LineType::Deletion => {
103 println!("{}", content.red());
104 }
105 LineType::Context => {
106 println!("{}", content.normal());
107 }
108 LineType::Header => {
109 println!("{}", content.cyan().bold());
110 }
111 }
112 }
113
114 pub fn render_changed_file(&self, file: &ChangedFile) {
115 if let Some(preview) = &file.preview {
116 self.render_file_preview(preview);
117 } else {
118 println!();
119 println!("{} {}", "changed".bold().green(), file.path.display());
120 }
121 }
122
123 #[allow(dead_code)]
124 pub fn render_changed_list(&self, report: &TransformationReport) {
125 for file in &report.changed_files {
126 if self.config.verbose || self.config.summary_only {
127 println!(
128 "{} {} (+{} -{})",
129 terminal::success_prefix(),
130 file.path.display(),
131 file.lines_added,
132 file.lines_removed
133 );
134 }
135 }
136 }
137
138 pub fn render_skipped_file(&self, path: &Path, reason: &str) {
139 println!(
140 " {} {} ({})",
141 "skip".bold().dimmed(),
142 path.display(),
143 reason
144 );
145 }
146
147 pub fn render_report(&self, report: &TransformationReport) {
148 println!();
149 println!("{}", "=".repeat(60).cyan());
150 println!("{}", terminal::label("Transformation Report"));
151 println!("{}", "=".repeat(60).cyan());
152
153 if !report.changed_files.is_empty() {
154 println!();
155 println!("{}", "Changed Files:".bold().green());
156 for file in &report.changed_files {
157 self.render_changed_file(file);
158 }
159 }
160
161 if !report.skipped_files.is_empty() {
162 println!();
163 println!("{}", "⚡ Skipped Files (Grouped by Reason):".bold().yellow());
164 let mut grouped_skipped: std::collections::HashMap<String, Vec<&super::preview::SkippedFile>> = std::collections::HashMap::new();
165 for skipped in &report.skipped_files {
166 grouped_skipped.entry(skipped.reason.to_string()).or_default().push(skipped);
167 }
168 for (reason, files) in grouped_skipped {
169 println!(" └─ {} ({} files):", reason.bold().cyan(), files.len());
170 for f in files.iter().take(5) {
171 println!(" • {}", f.path.display());
172 }
173 if files.len() > 5 {
174 println!(" • ... and {} more files", files.len() - 5);
175 }
176 }
177 }
178
179 println!();
180 println!("{}", terminal::label("Statistics"));
181 println!(" total changed: {}", report.total_changed());
182 println!(" lines added: {}", report.total_lines_added());
183 println!(" lines removed: {}", report.total_lines_removed());
184 println!(" files skipped: {}", report.total_files_skipped());
185 println!(" execution: {}ms", report.execution_time_ms);
186 }
187}
188
189mod terminal {
190 use colored::Colorize;
191
192 pub fn label(text: &str) -> colored::ColoredString {
193 text.bold().cyan()
194 }
195
196 #[allow(dead_code)]
197 pub fn success_prefix() -> colored::ColoredString {
198 "done".bold().green()
199 }
200}