Skip to main content

codelens_core/output/
console.rs

1//! Console output with colors and formatting.
2
3use 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
13/// Console output formatter.
14pub struct ConsoleOutput;
15
16impl ConsoleOutput {
17    /// Create a new console output formatter.
18    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        // Header
95        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        // Summary table
106        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        // Language breakdown
153        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            // Apply top_n limit
174            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        // Footer
194        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        // Header
212        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        // Project score and grade
223        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        // Dimensions table
242        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            // By Directory table
263            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            // Worst Files table
289            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        // Header
327        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        // Header
391        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        // Delta table
412        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        // By Language table
457        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        // Per-model parameter summary
636        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}