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        }
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        // Header
91        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        // Summary table
102        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        // Language breakdown
149        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            // Apply top_n limit
170            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        // Footer
190        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        // Header
208        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        // Project score and grade
219        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        // Dimensions table
238        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            // By Directory table
259            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            // Worst Files table
285            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        // Header
323        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        // Header
387        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        // Delta table
408        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        // By Language table
453        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}