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 Report::Estimation(report) => self.write_estimation(report, options, writer),
78 Report::EstimationComparison(report) => {
79 self.write_estimation_comparison(report, writer)
80 }
81 }
82 }
83}
84
85impl ConsoleOutput {
86 fn write_analysis(
87 &self,
88 result: &AnalysisResult,
89 options: &OutputOptions,
90 writer: &mut dyn Write,
91 ) -> Result<()> {
92 let summary = &result.summary;
93
94 writeln!(writer)?;
96 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
97 writeln!(
98 writer,
99 "{}",
100 " CODELENS - Code Statistics Report ".bold().cyan()
101 )?;
102 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
103 writeln!(writer)?;
104
105 let mut table = Table::new();
107 table
108 .load_preset(UTF8_FULL)
109 .set_content_arrangement(ContentArrangement::Dynamic);
110
111 table.set_header(vec![
112 Cell::new("Metric").add_attribute(Attribute::Bold),
113 Cell::new("Value").add_attribute(Attribute::Bold),
114 ]);
115
116 table.add_row(vec![
117 Cell::new("Total Files"),
118 Cell::new(Self::format_number(summary.total_files)).fg(Color::Green),
119 ]);
120 table.add_row(vec![
121 Cell::new("Code Lines"),
122 Cell::new(Self::format_number(summary.lines.code)).fg(Color::Cyan),
123 ]);
124 table.add_row(vec![
125 Cell::new("Comment Lines"),
126 Cell::new(Self::format_number(summary.lines.comment)).fg(Color::Yellow),
127 ]);
128 table.add_row(vec![
129 Cell::new("Blank Lines"),
130 Cell::new(Self::format_number(summary.lines.blank)).fg(Color::DarkGrey),
131 ]);
132 table.add_row(vec![
133 Cell::new("Total Lines"),
134 Cell::new(Self::format_number(summary.lines.total)).add_attribute(Attribute::Bold),
135 ]);
136 table.add_row(vec![
137 Cell::new("Total Size"),
138 Cell::new(Self::format_size(summary.total_size)),
139 ]);
140 table.add_row(vec![
141 Cell::new("Languages"),
142 Cell::new(summary.by_language.len().to_string()),
143 ]);
144 table.add_row(vec![
145 Cell::new("Functions"),
146 Cell::new(Self::format_number(summary.complexity.functions)),
147 ]);
148
149 writeln!(writer, "{table}")?;
150 writeln!(writer)?;
151
152 if !options.summary_only && !summary.by_language.is_empty() {
154 writeln!(writer, "{}", "By Language".bold())?;
155 writeln!(writer)?;
156
157 let mut lang_table = Table::new();
158 lang_table
159 .load_preset(UTF8_FULL)
160 .set_content_arrangement(ContentArrangement::Dynamic);
161
162 lang_table.set_header(vec![
163 Cell::new("Language").add_attribute(Attribute::Bold),
164 Cell::new("Files").add_attribute(Attribute::Bold),
165 Cell::new("Code").add_attribute(Attribute::Bold),
166 Cell::new("Comment").add_attribute(Attribute::Bold),
167 Cell::new("Blank").add_attribute(Attribute::Bold),
168 Cell::new("Total").add_attribute(Attribute::Bold),
169 ]);
170
171 let mut langs: Vec<_> = summary.by_language.iter().collect();
172
173 if let Some(n) = options.top_n {
175 langs.truncate(n);
176 }
177
178 for (name, stats) in langs {
179 lang_table.add_row(vec![
180 Cell::new(name).fg(Color::Cyan),
181 Cell::new(Self::format_number(stats.files)),
182 Cell::new(Self::format_number(stats.lines.code)).fg(Color::Green),
183 Cell::new(Self::format_number(stats.lines.comment)).fg(Color::Yellow),
184 Cell::new(Self::format_number(stats.lines.blank)).fg(Color::DarkGrey),
185 Cell::new(Self::format_number(stats.lines.total)),
186 ]);
187 }
188
189 writeln!(writer, "{lang_table}")?;
190 writeln!(writer)?;
191 }
192
193 writeln!(writer, "{}", "─".repeat(60).dimmed())?;
195 writeln!(
196 writer,
197 "Scanned {} files in {:.2}s",
198 result.scanned_files.to_string().green(),
199 result.elapsed.as_secs_f64()
200 )?;
201
202 Ok(())
203 }
204
205 fn write_health(
206 &self,
207 report: &crate::insight::health::HealthReport,
208 options: &OutputOptions,
209 writer: &mut dyn Write,
210 ) -> Result<()> {
211 writeln!(writer)?;
213 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
214 writeln!(
215 writer,
216 "{}",
217 " CODELENS - Code Health Report ".bold().cyan()
218 )?;
219 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
220 writeln!(writer)?;
221
222 let grade_color = Self::grade_color(report.grade);
224 let grade_str = report.grade.to_string();
225 let colored_grade = match grade_color {
226 Color::Green => grade_str.green().bold().to_string(),
227 Color::Cyan => grade_str.cyan().bold().to_string(),
228 Color::Yellow => grade_str.yellow().bold().to_string(),
229 Color::Red => grade_str.red().bold().to_string(),
230 Color::DarkRed => grade_str.red().bold().to_string(),
231 _ => grade_str.bold().to_string(),
232 };
233 writeln!(
234 writer,
235 " Project Score: {} Grade: {}",
236 format!("{:.1}", report.score).bold(),
237 colored_grade,
238 )?;
239 writeln!(writer)?;
240
241 let mut dim_table = Table::new();
243 dim_table
244 .load_preset(UTF8_FULL)
245 .set_content_arrangement(ContentArrangement::Dynamic);
246 dim_table.set_header(vec![
247 Cell::new("Dimension").add_attribute(Attribute::Bold),
248 Cell::new("Score").add_attribute(Attribute::Bold),
249 Cell::new("Grade").add_attribute(Attribute::Bold),
250 ]);
251 for dim in &report.dimensions {
252 dim_table.add_row(vec![
253 Cell::new(dim.dimension.to_string()),
254 Cell::new(format!("{:.1}", dim.score)),
255 Cell::new(dim.grade.to_string()).fg(Self::grade_color(dim.grade)),
256 ]);
257 }
258 writeln!(writer, "{dim_table}")?;
259 writeln!(writer)?;
260
261 if !options.summary_only {
262 if !report.by_directory.is_empty() {
264 writeln!(writer, "{}", "By Directory".bold())?;
265 writeln!(writer)?;
266 let mut dir_table = Table::new();
267 dir_table
268 .load_preset(UTF8_FULL)
269 .set_content_arrangement(ContentArrangement::Dynamic);
270 dir_table.set_header(vec![
271 Cell::new("Directory").add_attribute(Attribute::Bold),
272 Cell::new("Score").add_attribute(Attribute::Bold),
273 Cell::new("Grade").add_attribute(Attribute::Bold),
274 Cell::new("Files").add_attribute(Attribute::Bold),
275 ]);
276 for dir in &report.by_directory {
277 dir_table.add_row(vec![
278 Cell::new(dir.path.display().to_string()).fg(Color::Cyan),
279 Cell::new(format!("{:.1}", dir.score)),
280 Cell::new(dir.grade.to_string()).fg(Self::grade_color(dir.grade)),
281 Cell::new(Self::format_number(dir.file_count)),
282 ]);
283 }
284 writeln!(writer, "{dir_table}")?;
285 writeln!(writer)?;
286 }
287
288 if !report.worst_files.is_empty() {
290 writeln!(writer, "{}", "Worst Files".bold())?;
291 writeln!(writer)?;
292 let mut file_table = Table::new();
293 file_table
294 .load_preset(UTF8_FULL)
295 .set_content_arrangement(ContentArrangement::Dynamic);
296 file_table.set_header(vec![
297 Cell::new("File").add_attribute(Attribute::Bold),
298 Cell::new("Score").add_attribute(Attribute::Bold),
299 Cell::new("Grade").add_attribute(Attribute::Bold),
300 Cell::new("Top Issue").add_attribute(Attribute::Bold),
301 ]);
302 for file in &report.worst_files {
303 file_table.add_row(vec![
304 Cell::new(file.path.display().to_string()).fg(Color::Cyan),
305 Cell::new(format!("{:.1}", file.score)),
306 Cell::new(file.grade.to_string()).fg(Self::grade_color(file.grade)),
307 Cell::new(file.top_issue.to_string()).fg(Color::Yellow),
308 ]);
309 }
310 writeln!(writer, "{file_table}")?;
311 writeln!(writer)?;
312 }
313 }
314
315 Ok(())
316 }
317
318 fn write_hotspot(
319 &self,
320 report: &crate::insight::hotspot::HotspotReport,
321 _options: &OutputOptions,
322 writer: &mut dyn Write,
323 ) -> Result<()> {
324 use crate::insight::hotspot::RiskLevel;
325
326 writeln!(writer)?;
328 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
329 writeln!(writer, "{}", " CODELENS - Hotspot Analysis ".bold().cyan())?;
330 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
331 writeln!(writer)?;
332
333 writeln!(
334 writer,
335 " Period: {} Total Commits: {}",
336 report.since.bold(),
337 Self::format_number(report.total_commits).bold()
338 )?;
339 writeln!(writer)?;
340
341 if report.files.is_empty() {
342 writeln!(writer, " No hotspots found.")?;
343 return Ok(());
344 }
345
346 let mut table = Table::new();
347 table
348 .load_preset(UTF8_FULL)
349 .set_content_arrangement(ContentArrangement::Dynamic);
350 table.set_header(vec![
351 Cell::new("File").add_attribute(Attribute::Bold),
352 Cell::new("Chg").add_attribute(Attribute::Bold),
353 Cell::new("+/-").add_attribute(Attribute::Bold),
354 Cell::new("CC").add_attribute(Attribute::Bold),
355 Cell::new("Score").add_attribute(Attribute::Bold),
356 Cell::new("Risk").add_attribute(Attribute::Bold),
357 ]);
358
359 for file in &report.files {
360 let risk_color = match file.risk {
361 RiskLevel::High => Color::Red,
362 RiskLevel::Medium => Color::Yellow,
363 RiskLevel::Low => Color::Green,
364 };
365 table.add_row(vec![
366 Cell::new(file.path.display().to_string()).fg(Color::Cyan),
367 Cell::new(Self::format_number(file.churn.commits)),
368 Cell::new(format!(
369 "+{}/-{}",
370 file.churn.lines_added, file.churn.lines_deleted
371 )),
372 Cell::new(file.complexity.cyclomatic.to_string()),
373 Cell::new(format!("{:.2}", file.hotspot_score)),
374 Cell::new(file.risk.to_string()).fg(risk_color),
375 ]);
376 }
377
378 writeln!(writer, "{table}")?;
379 writeln!(writer)?;
380
381 Ok(())
382 }
383
384 fn write_trend(
385 &self,
386 report: &crate::insight::trend::TrendReport,
387 _options: &OutputOptions,
388 writer: &mut dyn Write,
389 ) -> Result<()> {
390 writeln!(writer)?;
392 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
393 writeln!(writer, "{}", " CODELENS - Trend Report ".bold().cyan())?;
394 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
395 writeln!(writer)?;
396
397 let from_label = report.from.label.as_deref().unwrap_or_default();
398 let to_label = report.to.label.as_deref().unwrap_or_default();
399 writeln!(
400 writer,
401 " {} {} {} {} {} {}",
402 "From:".bold(),
403 report.from.timestamp.format("%Y-%m-%d"),
404 from_label,
405 "To:".bold(),
406 report.to.timestamp.format("%Y-%m-%d"),
407 to_label,
408 )?;
409 writeln!(writer)?;
410
411 let mut delta_table = Table::new();
413 delta_table
414 .load_preset(UTF8_FULL)
415 .set_content_arrangement(ContentArrangement::Dynamic);
416 delta_table.set_header(vec![
417 Cell::new("Metric").add_attribute(Attribute::Bold),
418 Cell::new("Before").add_attribute(Attribute::Bold),
419 Cell::new("After").add_attribute(Attribute::Bold),
420 Cell::new("Delta").add_attribute(Attribute::Bold),
421 Cell::new("Change").add_attribute(Attribute::Bold),
422 ]);
423
424 let deltas = [
425 ("Files", &report.delta.files),
426 ("Lines", &report.delta.lines),
427 ("Code", &report.delta.code),
428 ("Comments", &report.delta.comment),
429 ("Blank", &report.delta.blank),
430 ("Complexity", &report.delta.complexity),
431 ("Functions", &report.delta.functions),
432 ];
433
434 for (name, dv) in &deltas {
435 let signed = dv.signed_delta();
436 let delta_color = if signed > 0 {
437 Color::Green
438 } else if signed < 0 {
439 Color::Red
440 } else {
441 Color::White
442 };
443 let sign = if signed > 0 { "+" } else { "" };
444 delta_table.add_row(vec![
445 Cell::new(*name),
446 Cell::new(Self::format_number(dv.from)),
447 Cell::new(Self::format_number(dv.to)),
448 Cell::new(format!("{sign}{signed}")).fg(delta_color),
449 Cell::new(format!("{:+.1}%", dv.percent)).fg(delta_color),
450 ]);
451 }
452
453 writeln!(writer, "{delta_table}")?;
454 writeln!(writer)?;
455
456 if !report.by_language.is_empty() {
458 writeln!(writer, "{}", "By Language".bold())?;
459 writeln!(writer)?;
460 let mut lang_table = Table::new();
461 lang_table
462 .load_preset(UTF8_FULL)
463 .set_content_arrangement(ContentArrangement::Dynamic);
464 lang_table.set_header(vec![
465 Cell::new("Language").add_attribute(Attribute::Bold),
466 Cell::new("Status").add_attribute(Attribute::Bold),
467 Cell::new("Before").add_attribute(Attribute::Bold),
468 Cell::new("After").add_attribute(Attribute::Bold),
469 Cell::new("Delta").add_attribute(Attribute::Bold),
470 ]);
471
472 for lang in &report.by_language {
473 let signed = lang.code.signed_delta();
474 let delta_color = if signed > 0 {
475 Color::Green
476 } else if signed < 0 {
477 Color::Red
478 } else {
479 Color::White
480 };
481 let sign = if signed > 0 { "+" } else { "" };
482 lang_table.add_row(vec![
483 Cell::new(&lang.language).fg(Color::Cyan),
484 Cell::new(lang.status.to_string()),
485 Cell::new(Self::format_number(lang.code.from)),
486 Cell::new(Self::format_number(lang.code.to)),
487 Cell::new(format!("{sign}{signed}")).fg(delta_color),
488 ]);
489 }
490
491 writeln!(writer, "{lang_table}")?;
492 writeln!(writer)?;
493 }
494
495 Ok(())
496 }
497
498 fn format_cost(cost: f64) -> String {
499 if cost >= 1_000_000.0 {
500 format!("{:.2}M", cost / 1_000_000.0)
501 } else if cost >= 1_000.0 {
502 format!("{:.0}", cost)
503 } else {
504 format!("{:.2}", cost)
505 }
506 }
507
508 fn write_estimation(
509 &self,
510 report: &crate::insight::estimation::EstimationReport,
511 options: &OutputOptions,
512 writer: &mut dyn Write,
513 ) -> Result<()> {
514 writeln!(writer)?;
515 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
516 writeln!(
517 writer,
518 "{}",
519 " CODELENS - Cost Estimation Report ".bold().cyan()
520 )?;
521 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
522 writeln!(writer)?;
523 writeln!(writer, " Model: {}", report.model.bold())?;
524 writeln!(writer)?;
525
526 let mut table = Table::new();
527 table
528 .load_preset(UTF8_FULL)
529 .set_content_arrangement(ContentArrangement::Dynamic);
530 table.set_header(vec![
531 Cell::new("Metric").add_attribute(Attribute::Bold),
532 Cell::new("Value").add_attribute(Attribute::Bold),
533 ]);
534 table.add_row(vec![
535 Cell::new("Total SLOC"),
536 Cell::new(Self::format_number(report.total_sloc)).fg(Color::Cyan),
537 ]);
538 table.add_row(vec![
539 Cell::new("Estimated Cost to Develop"),
540 Cell::new(format!("${}", Self::format_cost(report.estimated_cost))).fg(Color::Green),
541 ]);
542 table.add_row(vec![
543 Cell::new("Estimated Schedule Effort"),
544 Cell::new(format!("{:.2} months", report.schedule_months)).fg(Color::Yellow),
545 ]);
546 table.add_row(vec![
547 Cell::new("Estimated People Required"),
548 Cell::new(format!("{:.2}", report.people_required)).fg(Color::Magenta),
549 ]);
550 writeln!(writer, "{table}")?;
551 writeln!(writer)?;
552
553 if !options.summary_only && !report.by_language.is_empty() {
554 writeln!(writer, "{}", "By Language".bold())?;
555 writeln!(writer)?;
556 let mut lang_table = Table::new();
557 lang_table
558 .load_preset(UTF8_FULL)
559 .set_content_arrangement(ContentArrangement::Dynamic);
560 lang_table.set_header(vec![
561 Cell::new("Language").add_attribute(Attribute::Bold),
562 Cell::new("Code").add_attribute(Attribute::Bold),
563 Cell::new("Effort (PM)").add_attribute(Attribute::Bold),
564 Cell::new("Cost").add_attribute(Attribute::Bold),
565 ]);
566 let mut langs = report.by_language.iter().collect::<Vec<_>>();
567 if let Some(n) = options.top_n {
568 langs.truncate(n);
569 }
570 for lang in langs {
571 lang_table.add_row(vec![
572 Cell::new(&lang.language).fg(Color::Cyan),
573 Cell::new(Self::format_number(lang.code_lines)),
574 Cell::new(format!("{:.2}", lang.effort_months)),
575 Cell::new(format!("${}", Self::format_cost(lang.cost))).fg(Color::Green),
576 ]);
577 }
578 writeln!(writer, "{lang_table}")?;
579 writeln!(writer)?;
580 }
581
582 writeln!(writer, "{}", "─".repeat(60).dimmed())?;
583 for (key, val) in &report.params {
584 write!(writer, "{} ", format!("{key}: {val}").dimmed())?;
585 }
586 writeln!(writer)?;
587
588 Ok(())
589 }
590
591 fn write_estimation_comparison(
592 &self,
593 report: &crate::insight::estimation::EstimationComparison,
594 writer: &mut dyn Write,
595 ) -> Result<()> {
596 writeln!(writer)?;
597 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
598 writeln!(
599 writer,
600 "{}",
601 " CODELENS - Cost Estimation Comparison ".bold().cyan()
602 )?;
603 writeln!(writer, "{}", "═".repeat(60).dimmed())?;
604 writeln!(writer)?;
605 writeln!(
606 writer,
607 " Total SLOC: {}",
608 Self::format_number(report.total_sloc).bold()
609 )?;
610 writeln!(writer)?;
611
612 let mut table = Table::new();
613 table
614 .load_preset(UTF8_FULL)
615 .set_content_arrangement(ContentArrangement::Dynamic);
616 table.set_header(vec![
617 Cell::new("Model").add_attribute(Attribute::Bold),
618 Cell::new("Effort (PM)").add_attribute(Attribute::Bold),
619 Cell::new("Schedule (M)").add_attribute(Attribute::Bold),
620 Cell::new("People").add_attribute(Attribute::Bold),
621 Cell::new("Cost").add_attribute(Attribute::Bold),
622 ]);
623 for r in &report.reports {
624 table.add_row(vec![
625 Cell::new(&r.model).fg(Color::Cyan),
626 Cell::new(format!("{:.2}", r.effort_months)),
627 Cell::new(format!("{:.2}", r.schedule_months)).fg(Color::Yellow),
628 Cell::new(format!("{:.2}", r.people_required)).fg(Color::Magenta),
629 Cell::new(format!("${}", Self::format_cost(r.estimated_cost))).fg(Color::Green),
630 ]);
631 }
632 writeln!(writer, "{table}")?;
633 writeln!(writer)?;
634
635 writeln!(writer, "{}", "─".repeat(60).dimmed())?;
637 for r in &report.reports {
638 let params: Vec<String> = r.params.iter().map(|(k, v)| format!("{k}={v}")).collect();
639 writeln!(
640 writer,
641 "{}",
642 format!("{}: {}", r.model, params.join(", ")).dimmed()
643 )?;
644 }
645
646 Ok(())
647 }
648
649 fn grade_color(grade: crate::insight::Grade) -> Color {
650 use crate::insight::Grade;
651 match grade {
652 Grade::A => Color::Green,
653 Grade::B => Color::Cyan,
654 Grade::C => Color::Yellow,
655 Grade::D => Color::Red,
656 Grade::F => Color::DarkRed,
657 }
658 }
659}