1use std::io::Write;
4
5use colored::Colorize;
6use comfy_table::{presets::UTF8_FULL, Attribute, Cell, Color, ContentArrangement, Table};
7
8use crate::analyzer::stats::AnalysisResult;
9use crate::error::Result;
10
11use super::format::{OutputFormat, OutputOptions, Report};
12
13pub struct ConsoleOutput;
15
16impl ConsoleOutput {
17 pub fn new() -> Self {
19 Self
20 }
21
22 fn format_size(bytes: u64) -> String {
23 const KB: u64 = 1024;
24 const MB: u64 = KB * 1024;
25 const GB: u64 = MB * 1024;
26
27 if bytes >= GB {
28 format!("{:.2} GB", bytes as f64 / GB as f64)
29 } else if bytes >= MB {
30 format!("{:.2} MB", bytes as f64 / MB as f64)
31 } else if bytes >= KB {
32 format!("{:.2} KB", bytes as f64 / KB as f64)
33 } else {
34 format!("{} B", bytes)
35 }
36 }
37
38 fn format_number(n: usize) -> String {
39 let s = n.to_string();
40 let mut result = String::new();
41 for (i, c) in s.chars().rev().enumerate() {
42 if i > 0 && i % 3 == 0 {
43 result.push(',');
44 }
45 result.push(c);
46 }
47 result.chars().rev().collect()
48 }
49}
50
51impl Default for ConsoleOutput {
52 fn default() -> Self {
53 Self::new()
54 }
55}
56
57impl OutputFormat for ConsoleOutput {
58 fn name(&self) -> &'static str {
59 "console"
60 }
61
62 fn extension(&self) -> &'static str {
63 "txt"
64 }
65
66 fn write(
67 &self,
68 report: &Report,
69 options: &OutputOptions,
70 writer: &mut dyn Write,
71 ) -> Result<()> {
72 match report {
73 Report::Analysis(result) => self.write_analysis(result, options, writer),
74 Report::Health(report) => self.write_health(report, options, writer),
75 Report::Hotspot(report) => self.write_hotspot(report, options, writer),
76 Report::Trend(report) => self.write_trend(report, options, writer),
77 }
78 }
79}
80
81impl ConsoleOutput {
82 fn write_analysis(
83 &self,
84 result: &AnalysisResult,
85 options: &OutputOptions,
86 writer: &mut dyn Write,
87 ) -> Result<()> {
88 let summary = &result.summary;
89
90 writeln!(writer)?;
92 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
93 writeln!(
94 writer,
95 "{}",
96 " CODELENS - Code Statistics Report ".bold().cyan()
97 )?;
98 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
99 writeln!(writer)?;
100
101 let mut table = Table::new();
103 table
104 .load_preset(UTF8_FULL)
105 .set_content_arrangement(ContentArrangement::Dynamic);
106
107 table.set_header(vec![
108 Cell::new("Metric").add_attribute(Attribute::Bold),
109 Cell::new("Value").add_attribute(Attribute::Bold),
110 ]);
111
112 table.add_row(vec![
113 Cell::new("Total Files"),
114 Cell::new(Self::format_number(summary.total_files)).fg(Color::Green),
115 ]);
116 table.add_row(vec![
117 Cell::new("Code Lines"),
118 Cell::new(Self::format_number(summary.lines.code)).fg(Color::Cyan),
119 ]);
120 table.add_row(vec![
121 Cell::new("Comment Lines"),
122 Cell::new(Self::format_number(summary.lines.comment)).fg(Color::Yellow),
123 ]);
124 table.add_row(vec![
125 Cell::new("Blank Lines"),
126 Cell::new(Self::format_number(summary.lines.blank)).fg(Color::DarkGrey),
127 ]);
128 table.add_row(vec![
129 Cell::new("Total Lines"),
130 Cell::new(Self::format_number(summary.lines.total)).add_attribute(Attribute::Bold),
131 ]);
132 table.add_row(vec![
133 Cell::new("Total Size"),
134 Cell::new(Self::format_size(summary.total_size)),
135 ]);
136 table.add_row(vec![
137 Cell::new("Languages"),
138 Cell::new(summary.by_language.len().to_string()),
139 ]);
140 table.add_row(vec![
141 Cell::new("Functions"),
142 Cell::new(Self::format_number(summary.complexity.functions)),
143 ]);
144
145 writeln!(writer, "{table}")?;
146 writeln!(writer)?;
147
148 if !options.summary_only && !summary.by_language.is_empty() {
150 writeln!(writer, "{}", "By Language".bold())?;
151 writeln!(writer)?;
152
153 let mut lang_table = Table::new();
154 lang_table
155 .load_preset(UTF8_FULL)
156 .set_content_arrangement(ContentArrangement::Dynamic);
157
158 lang_table.set_header(vec![
159 Cell::new("Language").add_attribute(Attribute::Bold),
160 Cell::new("Files").add_attribute(Attribute::Bold),
161 Cell::new("Code").add_attribute(Attribute::Bold),
162 Cell::new("Comment").add_attribute(Attribute::Bold),
163 Cell::new("Blank").add_attribute(Attribute::Bold),
164 Cell::new("Total").add_attribute(Attribute::Bold),
165 ]);
166
167 let mut langs: Vec<_> = summary.by_language.iter().collect();
168
169 if let Some(n) = options.top_n {
171 langs.truncate(n);
172 }
173
174 for (name, stats) in langs {
175 lang_table.add_row(vec![
176 Cell::new(name).fg(Color::Cyan),
177 Cell::new(Self::format_number(stats.files)),
178 Cell::new(Self::format_number(stats.lines.code)).fg(Color::Green),
179 Cell::new(Self::format_number(stats.lines.comment)).fg(Color::Yellow),
180 Cell::new(Self::format_number(stats.lines.blank)).fg(Color::DarkGrey),
181 Cell::new(Self::format_number(stats.lines.total)),
182 ]);
183 }
184
185 writeln!(writer, "{lang_table}")?;
186 writeln!(writer)?;
187 }
188
189 writeln!(writer, "{}", "─".repeat(60).dimmed())?;
191 writeln!(
192 writer,
193 "Scanned {} files in {:.2}s",
194 result.scanned_files.to_string().green(),
195 result.elapsed.as_secs_f64()
196 )?;
197
198 Ok(())
199 }
200
201 fn write_health(
202 &self,
203 report: &crate::insight::health::HealthReport,
204 options: &OutputOptions,
205 writer: &mut dyn Write,
206 ) -> Result<()> {
207 writeln!(writer)?;
209 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
210 writeln!(
211 writer,
212 "{}",
213 " CODELENS - Code Health Report ".bold().cyan()
214 )?;
215 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
216 writeln!(writer)?;
217
218 let grade_color = Self::grade_color(report.grade);
220 let grade_str = report.grade.to_string();
221 let colored_grade = match grade_color {
222 Color::Green => grade_str.green().bold().to_string(),
223 Color::Cyan => grade_str.cyan().bold().to_string(),
224 Color::Yellow => grade_str.yellow().bold().to_string(),
225 Color::Red => grade_str.red().bold().to_string(),
226 Color::DarkRed => grade_str.red().bold().to_string(),
227 _ => grade_str.bold().to_string(),
228 };
229 writeln!(
230 writer,
231 " Project Score: {} Grade: {}",
232 format!("{:.1}", report.score).bold(),
233 colored_grade,
234 )?;
235 writeln!(writer)?;
236
237 let mut dim_table = Table::new();
239 dim_table
240 .load_preset(UTF8_FULL)
241 .set_content_arrangement(ContentArrangement::Dynamic);
242 dim_table.set_header(vec![
243 Cell::new("Dimension").add_attribute(Attribute::Bold),
244 Cell::new("Score").add_attribute(Attribute::Bold),
245 Cell::new("Grade").add_attribute(Attribute::Bold),
246 ]);
247 for dim in &report.dimensions {
248 dim_table.add_row(vec![
249 Cell::new(dim.dimension.to_string()),
250 Cell::new(format!("{:.1}", dim.score)),
251 Cell::new(dim.grade.to_string()).fg(Self::grade_color(dim.grade)),
252 ]);
253 }
254 writeln!(writer, "{dim_table}")?;
255 writeln!(writer)?;
256
257 if !options.summary_only {
258 if !report.by_directory.is_empty() {
260 writeln!(writer, "{}", "By Directory".bold())?;
261 writeln!(writer)?;
262 let mut dir_table = Table::new();
263 dir_table
264 .load_preset(UTF8_FULL)
265 .set_content_arrangement(ContentArrangement::Dynamic);
266 dir_table.set_header(vec![
267 Cell::new("Directory").add_attribute(Attribute::Bold),
268 Cell::new("Score").add_attribute(Attribute::Bold),
269 Cell::new("Grade").add_attribute(Attribute::Bold),
270 Cell::new("Files").add_attribute(Attribute::Bold),
271 ]);
272 for dir in &report.by_directory {
273 dir_table.add_row(vec![
274 Cell::new(dir.path.display().to_string()).fg(Color::Cyan),
275 Cell::new(format!("{:.1}", dir.score)),
276 Cell::new(dir.grade.to_string()).fg(Self::grade_color(dir.grade)),
277 Cell::new(Self::format_number(dir.file_count)),
278 ]);
279 }
280 writeln!(writer, "{dir_table}")?;
281 writeln!(writer)?;
282 }
283
284 if !report.worst_files.is_empty() {
286 writeln!(writer, "{}", "Worst Files".bold())?;
287 writeln!(writer)?;
288 let mut file_table = Table::new();
289 file_table
290 .load_preset(UTF8_FULL)
291 .set_content_arrangement(ContentArrangement::Dynamic);
292 file_table.set_header(vec![
293 Cell::new("File").add_attribute(Attribute::Bold),
294 Cell::new("Score").add_attribute(Attribute::Bold),
295 Cell::new("Grade").add_attribute(Attribute::Bold),
296 Cell::new("Top Issue").add_attribute(Attribute::Bold),
297 ]);
298 for file in &report.worst_files {
299 file_table.add_row(vec![
300 Cell::new(file.path.display().to_string()).fg(Color::Cyan),
301 Cell::new(format!("{:.1}", file.score)),
302 Cell::new(file.grade.to_string()).fg(Self::grade_color(file.grade)),
303 Cell::new(file.top_issue.to_string()).fg(Color::Yellow),
304 ]);
305 }
306 writeln!(writer, "{file_table}")?;
307 writeln!(writer)?;
308 }
309 }
310
311 Ok(())
312 }
313
314 fn write_hotspot(
315 &self,
316 report: &crate::insight::hotspot::HotspotReport,
317 _options: &OutputOptions,
318 writer: &mut dyn Write,
319 ) -> Result<()> {
320 use crate::insight::hotspot::RiskLevel;
321
322 writeln!(writer)?;
324 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
325 writeln!(writer, "{}", " CODELENS - Hotspot Analysis ".bold().cyan())?;
326 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
327 writeln!(writer)?;
328
329 writeln!(
330 writer,
331 " Period: {} Total Commits: {}",
332 report.since.bold(),
333 Self::format_number(report.total_commits).bold()
334 )?;
335 writeln!(writer)?;
336
337 if report.files.is_empty() {
338 writeln!(writer, " No hotspots found.")?;
339 return Ok(());
340 }
341
342 let mut table = Table::new();
343 table
344 .load_preset(UTF8_FULL)
345 .set_content_arrangement(ContentArrangement::Dynamic);
346 table.set_header(vec![
347 Cell::new("File").add_attribute(Attribute::Bold),
348 Cell::new("Chg").add_attribute(Attribute::Bold),
349 Cell::new("+/-").add_attribute(Attribute::Bold),
350 Cell::new("CC").add_attribute(Attribute::Bold),
351 Cell::new("Score").add_attribute(Attribute::Bold),
352 Cell::new("Risk").add_attribute(Attribute::Bold),
353 ]);
354
355 for file in &report.files {
356 let risk_color = match file.risk {
357 RiskLevel::High => Color::Red,
358 RiskLevel::Medium => Color::Yellow,
359 RiskLevel::Low => Color::Green,
360 };
361 table.add_row(vec![
362 Cell::new(file.path.display().to_string()).fg(Color::Cyan),
363 Cell::new(Self::format_number(file.churn.commits)),
364 Cell::new(format!(
365 "+{}/-{}",
366 file.churn.lines_added, file.churn.lines_deleted
367 )),
368 Cell::new(file.complexity.cyclomatic.to_string()),
369 Cell::new(format!("{:.2}", file.hotspot_score)),
370 Cell::new(file.risk.to_string()).fg(risk_color),
371 ]);
372 }
373
374 writeln!(writer, "{table}")?;
375 writeln!(writer)?;
376
377 Ok(())
378 }
379
380 fn write_trend(
381 &self,
382 report: &crate::insight::trend::TrendReport,
383 _options: &OutputOptions,
384 writer: &mut dyn Write,
385 ) -> Result<()> {
386 writeln!(writer)?;
388 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
389 writeln!(writer, "{}", " CODELENS - Trend Report ".bold().cyan())?;
390 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
391 writeln!(writer)?;
392
393 let from_label = report.from.label.as_deref().unwrap_or_default();
394 let to_label = report.to.label.as_deref().unwrap_or_default();
395 writeln!(
396 writer,
397 " {} {} {} {} {} {}",
398 "From:".bold(),
399 report.from.timestamp.format("%Y-%m-%d"),
400 from_label,
401 "To:".bold(),
402 report.to.timestamp.format("%Y-%m-%d"),
403 to_label,
404 )?;
405 writeln!(writer)?;
406
407 let mut delta_table = Table::new();
409 delta_table
410 .load_preset(UTF8_FULL)
411 .set_content_arrangement(ContentArrangement::Dynamic);
412 delta_table.set_header(vec![
413 Cell::new("Metric").add_attribute(Attribute::Bold),
414 Cell::new("Before").add_attribute(Attribute::Bold),
415 Cell::new("After").add_attribute(Attribute::Bold),
416 Cell::new("Delta").add_attribute(Attribute::Bold),
417 Cell::new("Change").add_attribute(Attribute::Bold),
418 ]);
419
420 let deltas = [
421 ("Files", &report.delta.files),
422 ("Lines", &report.delta.lines),
423 ("Code", &report.delta.code),
424 ("Comments", &report.delta.comment),
425 ("Blank", &report.delta.blank),
426 ("Complexity", &report.delta.complexity),
427 ("Functions", &report.delta.functions),
428 ];
429
430 for (name, dv) in &deltas {
431 let signed = dv.signed_delta();
432 let delta_color = if signed > 0 {
433 Color::Green
434 } else if signed < 0 {
435 Color::Red
436 } else {
437 Color::White
438 };
439 let sign = if signed > 0 { "+" } else { "" };
440 delta_table.add_row(vec![
441 Cell::new(*name),
442 Cell::new(Self::format_number(dv.from)),
443 Cell::new(Self::format_number(dv.to)),
444 Cell::new(format!("{sign}{signed}")).fg(delta_color),
445 Cell::new(format!("{:+.1}%", dv.percent)).fg(delta_color),
446 ]);
447 }
448
449 writeln!(writer, "{delta_table}")?;
450 writeln!(writer)?;
451
452 if !report.by_language.is_empty() {
454 writeln!(writer, "{}", "By Language".bold())?;
455 writeln!(writer)?;
456 let mut lang_table = Table::new();
457 lang_table
458 .load_preset(UTF8_FULL)
459 .set_content_arrangement(ContentArrangement::Dynamic);
460 lang_table.set_header(vec![
461 Cell::new("Language").add_attribute(Attribute::Bold),
462 Cell::new("Status").add_attribute(Attribute::Bold),
463 Cell::new("Before").add_attribute(Attribute::Bold),
464 Cell::new("After").add_attribute(Attribute::Bold),
465 Cell::new("Delta").add_attribute(Attribute::Bold),
466 ]);
467
468 for lang in &report.by_language {
469 let signed = lang.code.signed_delta();
470 let delta_color = if signed > 0 {
471 Color::Green
472 } else if signed < 0 {
473 Color::Red
474 } else {
475 Color::White
476 };
477 let sign = if signed > 0 { "+" } else { "" };
478 lang_table.add_row(vec![
479 Cell::new(&lang.language).fg(Color::Cyan),
480 Cell::new(lang.status.to_string()),
481 Cell::new(Self::format_number(lang.code.from)),
482 Cell::new(Self::format_number(lang.code.to)),
483 Cell::new(format!("{sign}{signed}")).fg(delta_color),
484 ]);
485 }
486
487 writeln!(writer, "{lang_table}")?;
488 writeln!(writer)?;
489 }
490
491 Ok(())
492 }
493
494 fn grade_color(grade: crate::insight::Grade) -> Color {
495 use crate::insight::Grade;
496 match grade {
497 Grade::A => Color::Green,
498 Grade::B => Color::Cyan,
499 Grade::C => Color::Yellow,
500 Grade::D => Color::Red,
501 Grade::F => Color::DarkRed,
502 }
503 }
504}