Skip to main content

codelens_core/output/
markdown.rs

1//! Markdown output format.
2
3use std::io::Write;
4
5use crate::analyzer::stats::AnalysisResult;
6use crate::error::Result;
7
8use super::format::{OutputFormat, OutputOptions, Report};
9
10/// Markdown output formatter.
11pub struct MarkdownOutput;
12
13impl MarkdownOutput {
14    /// Create a new Markdown output formatter.
15    pub fn new() -> Self {
16        Self
17    }
18}
19
20impl Default for MarkdownOutput {
21    fn default() -> Self {
22        Self::new()
23    }
24}
25
26impl OutputFormat for MarkdownOutput {
27    fn name(&self) -> &'static str {
28        "markdown"
29    }
30
31    fn extension(&self) -> &'static str {
32        "md"
33    }
34
35    fn write(
36        &self,
37        report: &Report,
38        options: &OutputOptions,
39        writer: &mut dyn Write,
40    ) -> Result<()> {
41        match report {
42            Report::Analysis(result) => self.write_analysis(result, options, writer),
43            Report::Health(report) => self.write_health(report, options, writer),
44            Report::Hotspot(report) => self.write_hotspot(report, options, writer),
45            Report::Trend(report) => self.write_trend(report, options, writer),
46            Report::Estimation(report) => self.write_estimation(report, options, writer),
47            Report::EstimationComparison(report) => {
48                self.write_estimation_comparison(report, writer)
49            }
50        }
51    }
52}
53
54impl MarkdownOutput {
55    fn write_analysis(
56        &self,
57        result: &AnalysisResult,
58        options: &OutputOptions,
59        writer: &mut dyn Write,
60    ) -> Result<()> {
61        let summary = &result.summary;
62
63        writeln!(writer, "# Code Statistics Report")?;
64        writeln!(writer)?;
65
66        // Summary
67        writeln!(writer, "## Summary")?;
68        writeln!(writer)?;
69        writeln!(writer, "| Metric | Value |")?;
70        writeln!(writer, "|--------|-------|")?;
71        writeln!(writer, "| Total Files | {} |", summary.total_files)?;
72        writeln!(writer, "| Code Lines | {} |", summary.lines.code)?;
73        writeln!(writer, "| Comment Lines | {} |", summary.lines.comment)?;
74        writeln!(writer, "| Blank Lines | {} |", summary.lines.blank)?;
75        writeln!(writer, "| Total Lines | {} |", summary.lines.total)?;
76        writeln!(writer, "| Languages | {} |", summary.by_language.len())?;
77        writeln!(writer)?;
78
79        // Language breakdown
80        if !options.summary_only && !summary.by_language.is_empty() {
81            writeln!(writer, "## By Language")?;
82            writeln!(writer)?;
83            writeln!(
84                writer,
85                "| Language | Files | Code | Comment | Blank | Total |"
86            )?;
87            writeln!(
88                writer,
89                "|----------|-------|------|---------|-------|-------|"
90            )?;
91
92            let mut langs: Vec<_> = summary.by_language.iter().collect();
93            if let Some(n) = options.top_n {
94                langs.truncate(n);
95            }
96
97            for (name, stats) in langs {
98                writeln!(
99                    writer,
100                    "| {} | {} | {} | {} | {} | {} |",
101                    name,
102                    stats.files,
103                    stats.lines.code,
104                    stats.lines.comment,
105                    stats.lines.blank,
106                    stats.lines.total
107                )?;
108            }
109            writeln!(writer)?;
110        }
111
112        writeln!(writer, "---")?;
113        writeln!(
114            writer,
115            "*Generated by codelens in {:.2}s*",
116            result.elapsed.as_secs_f64()
117        )?;
118
119        Ok(())
120    }
121
122    fn write_health(
123        &self,
124        report: &crate::insight::health::HealthReport,
125        options: &OutputOptions,
126        writer: &mut dyn Write,
127    ) -> Result<()> {
128        writeln!(writer, "# Code Health Report")?;
129        writeln!(writer)?;
130        writeln!(
131            writer,
132            "**Project Score:** {:.1} | **Grade:** {}",
133            report.score, report.grade
134        )?;
135        writeln!(writer)?;
136
137        // Dimensions
138        writeln!(writer, "## Dimensions")?;
139        writeln!(writer)?;
140        writeln!(writer, "| Dimension | Score | Grade |")?;
141        writeln!(writer, "|-----------|-------|-------|")?;
142        for dim in &report.dimensions {
143            writeln!(
144                writer,
145                "| {} | {:.1} | {} |",
146                dim.dimension, dim.score, dim.grade
147            )?;
148        }
149        writeln!(writer)?;
150
151        if !options.summary_only {
152            if !report.by_directory.is_empty() {
153                writeln!(writer, "## By Directory")?;
154                writeln!(writer)?;
155                writeln!(writer, "| Directory | Score | Grade | Files |")?;
156                writeln!(writer, "|-----------|-------|-------|-------|")?;
157                for dir in &report.by_directory {
158                    writeln!(
159                        writer,
160                        "| {} | {:.1} | {} | {} |",
161                        dir.path.display(),
162                        dir.score,
163                        dir.grade,
164                        dir.file_count
165                    )?;
166                }
167                writeln!(writer)?;
168            }
169
170            if !report.worst_files.is_empty() {
171                writeln!(writer, "## Worst Files")?;
172                writeln!(writer)?;
173                writeln!(writer, "| File | Score | Grade | Top Issue |")?;
174                writeln!(writer, "|------|-------|-------|-----------|")?;
175                for file in &report.worst_files {
176                    writeln!(
177                        writer,
178                        "| {} | {:.1} | {} | {} |",
179                        file.path.display(),
180                        file.score,
181                        file.grade,
182                        file.top_issue
183                    )?;
184                }
185                writeln!(writer)?;
186            }
187        }
188
189        Ok(())
190    }
191
192    fn write_hotspot(
193        &self,
194        report: &crate::insight::hotspot::HotspotReport,
195        _options: &OutputOptions,
196        writer: &mut dyn Write,
197    ) -> Result<()> {
198        writeln!(writer, "# Hotspot Analysis")?;
199        writeln!(writer)?;
200        writeln!(
201            writer,
202            "**Period:** {} | **Total Commits:** {}",
203            report.since, report.total_commits
204        )?;
205        writeln!(writer)?;
206
207        if report.files.is_empty() {
208            writeln!(writer, "No hotspots found.")?;
209            return Ok(());
210        }
211
212        writeln!(writer, "| File | Chg | +/- | CC | Score | Risk |")?;
213        writeln!(writer, "|------|-----|-----|----|-------|------|")?;
214        for file in &report.files {
215            writeln!(
216                writer,
217                "| {} | {} | +{}/-{} | {} | {:.2} | {} |",
218                file.path.display(),
219                file.churn.commits,
220                file.churn.lines_added,
221                file.churn.lines_deleted,
222                file.complexity.cyclomatic,
223                file.hotspot_score,
224                file.risk,
225            )?;
226        }
227        writeln!(writer)?;
228
229        Ok(())
230    }
231
232    fn write_trend(
233        &self,
234        report: &crate::insight::trend::TrendReport,
235        _options: &OutputOptions,
236        writer: &mut dyn Write,
237    ) -> Result<()> {
238        let from_label = report.from.label.as_deref().unwrap_or("");
239        let to_label = report.to.label.as_deref().unwrap_or("");
240
241        writeln!(writer, "# Trend Report")?;
242        writeln!(writer)?;
243        writeln!(
244            writer,
245            "**From:** {} {} | **To:** {} {}",
246            report.from.timestamp.format("%Y-%m-%d"),
247            from_label,
248            report.to.timestamp.format("%Y-%m-%d"),
249            to_label,
250        )?;
251        writeln!(writer)?;
252
253        // Delta table
254        writeln!(writer, "## Delta")?;
255        writeln!(writer)?;
256        writeln!(writer, "| Metric | Before | After | Delta | Change |")?;
257        writeln!(writer, "|--------|--------|-------|-------|--------|")?;
258        let deltas = [
259            ("Files", &report.delta.files),
260            ("Lines", &report.delta.lines),
261            ("Code", &report.delta.code),
262            ("Comments", &report.delta.comment),
263            ("Blank", &report.delta.blank),
264            ("Complexity", &report.delta.complexity),
265            ("Functions", &report.delta.functions),
266        ];
267        for (name, dv) in &deltas {
268            let signed = dv.signed_delta();
269            let sign = if signed > 0 { "+" } else { "" };
270            writeln!(
271                writer,
272                "| {} | {} | {} | {}{} | {:+.1}% |",
273                name, dv.from, dv.to, sign, signed, dv.percent,
274            )?;
275        }
276        writeln!(writer)?;
277
278        // By Language
279        if !report.by_language.is_empty() {
280            writeln!(writer, "## By Language")?;
281            writeln!(writer)?;
282            writeln!(writer, "| Language | Status | Before | After | Delta |")?;
283            writeln!(writer, "|----------|--------|--------|-------|-------|")?;
284            for lang in &report.by_language {
285                let signed = lang.code.signed_delta();
286                let sign = if signed > 0 { "+" } else { "" };
287                writeln!(
288                    writer,
289                    "| {} | {} | {} | {} | {}{} |",
290                    lang.language, lang.status, lang.code.from, lang.code.to, sign, signed,
291                )?;
292            }
293            writeln!(writer)?;
294        }
295
296        Ok(())
297    }
298
299    fn write_estimation(
300        &self,
301        report: &crate::insight::estimation::EstimationReport,
302        options: &OutputOptions,
303        writer: &mut dyn Write,
304    ) -> Result<()> {
305        writeln!(writer, "# Cost Estimation Report")?;
306        writeln!(writer)?;
307        writeln!(writer, "**Model:** {}", report.model)?;
308        writeln!(writer)?;
309        writeln!(writer, "## Summary")?;
310        writeln!(writer)?;
311        writeln!(writer, "| Metric | Value |")?;
312        writeln!(writer, "|--------|-------|")?;
313        writeln!(writer, "| Total SLOC | {} |", report.total_sloc)?;
314        writeln!(writer, "| Estimated Cost | ${:.2} |", report.estimated_cost)?;
315        writeln!(
316            writer,
317            "| Schedule Effort | {:.2} months |",
318            report.schedule_months
319        )?;
320        writeln!(
321            writer,
322            "| People Required | {:.2} |",
323            report.people_required
324        )?;
325        writeln!(writer)?;
326        if !options.summary_only && !report.by_language.is_empty() {
327            writeln!(writer, "## By Language")?;
328            writeln!(writer)?;
329            writeln!(writer, "| Language | Code | Effort (PM) | Cost |")?;
330            writeln!(writer, "|----------|------|-------------|------|")?;
331            let mut langs = report.by_language.iter().collect::<Vec<_>>();
332            if let Some(n) = options.top_n {
333                langs.truncate(n);
334            }
335            for lang in langs {
336                writeln!(
337                    writer,
338                    "| {} | {} | {:.2} | ${:.2} |",
339                    lang.language, lang.code_lines, lang.effort_months, lang.cost,
340                )?;
341            }
342            writeln!(writer)?;
343        }
344        writeln!(writer, "---")?;
345        let params_str: Vec<String> = report
346            .params
347            .iter()
348            .map(|(k, v)| format!("{k}: {v}"))
349            .collect();
350        writeln!(writer, "*{}*", params_str.join(" | "))?;
351        Ok(())
352    }
353
354    fn write_estimation_comparison(
355        &self,
356        report: &crate::insight::estimation::EstimationComparison,
357        writer: &mut dyn Write,
358    ) -> Result<()> {
359        writeln!(writer, "# Cost Estimation Comparison")?;
360        writeln!(writer)?;
361        writeln!(writer, "**Total SLOC:** {}", report.total_sloc)?;
362        writeln!(writer)?;
363        writeln!(
364            writer,
365            "| Model | Effort (PM) | Schedule (M) | People | Cost |"
366        )?;
367        writeln!(
368            writer,
369            "|-------|-------------|--------------|--------|------|"
370        )?;
371        for r in &report.reports {
372            writeln!(
373                writer,
374                "| {} | {:.2} | {:.2} | {:.2} | ${:.2} |",
375                r.model, r.effort_months, r.schedule_months, r.people_required, r.estimated_cost,
376            )?;
377        }
378        Ok(())
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::Report;
385    use super::*;
386    use crate::analyzer::stats::{FileStats, LineStats, Summary};
387    use std::path::PathBuf;
388    use std::time::Duration;
389
390    fn make_test_result() -> AnalysisResult {
391        let files = vec![
392            FileStats {
393                path: PathBuf::from("main.rs"),
394                language: "Rust".to_string(),
395                lines: LineStats {
396                    total: 100,
397                    code: 80,
398                    comment: 10,
399                    blank: 10,
400                },
401                size: 2000,
402                complexity: Default::default(),
403            },
404            FileStats {
405                path: PathBuf::from("test.py"),
406                language: "Python".to_string(),
407                lines: LineStats {
408                    total: 50,
409                    code: 40,
410                    comment: 5,
411                    blank: 5,
412                },
413                size: 1000,
414                complexity: Default::default(),
415            },
416        ];
417        AnalysisResult {
418            summary: Summary::from_file_stats(&files),
419            files,
420            elapsed: Duration::from_secs(1),
421            scanned_files: 2,
422            skipped_files: 0,
423        }
424    }
425
426    #[test]
427    fn test_markdown_output_name() {
428        let output = MarkdownOutput::new();
429        assert_eq!(output.name(), "markdown");
430        assert_eq!(output.extension(), "md");
431    }
432
433    #[test]
434    fn test_markdown_output_title() {
435        let output = MarkdownOutput;
436        let result = make_test_result();
437        let options = OutputOptions::default();
438
439        let mut buffer = Vec::new();
440        output
441            .write(&Report::Analysis(result), &options, &mut buffer)
442            .unwrap();
443
444        let md_str = String::from_utf8(buffer).unwrap();
445        assert!(md_str.starts_with("# Code Statistics Report"));
446    }
447
448    #[test]
449    fn test_markdown_output_summary_table() {
450        let output = MarkdownOutput;
451        let result = make_test_result();
452        let options = OutputOptions::default();
453
454        let mut buffer = Vec::new();
455        output
456            .write(&Report::Analysis(result), &options, &mut buffer)
457            .unwrap();
458
459        let md_str = String::from_utf8(buffer).unwrap();
460
461        assert!(md_str.contains("## Summary"));
462        assert!(md_str.contains("| Total Files | 2 |"));
463        assert!(md_str.contains("| Code Lines | 120 |"));
464    }
465
466    #[test]
467    fn test_markdown_output_language_breakdown() {
468        let output = MarkdownOutput;
469        let result = make_test_result();
470        let options = OutputOptions {
471            summary_only: false,
472            ..Default::default()
473        };
474
475        let mut buffer = Vec::new();
476        output
477            .write(&Report::Analysis(result), &options, &mut buffer)
478            .unwrap();
479
480        let md_str = String::from_utf8(buffer).unwrap();
481
482        assert!(md_str.contains("## By Language"));
483        assert!(md_str.contains("| Rust |"));
484        assert!(md_str.contains("| Python |"));
485    }
486
487    #[test]
488    fn test_markdown_output_summary_only() {
489        let output = MarkdownOutput;
490        let result = make_test_result();
491        let options = OutputOptions {
492            summary_only: true,
493            ..Default::default()
494        };
495
496        let mut buffer = Vec::new();
497        output
498            .write(&Report::Analysis(result), &options, &mut buffer)
499            .unwrap();
500
501        let md_str = String::from_utf8(buffer).unwrap();
502
503        assert!(md_str.contains("## Summary"));
504        assert!(!md_str.contains("## By Language"));
505    }
506
507    #[test]
508    fn test_markdown_output_top_n() {
509        let output = MarkdownOutput;
510        let result = make_test_result();
511        let options = OutputOptions {
512            top_n: Some(1),
513            ..Default::default()
514        };
515
516        let mut buffer = Vec::new();
517        output
518            .write(&Report::Analysis(result), &options, &mut buffer)
519            .unwrap();
520
521        let md_str = String::from_utf8(buffer).unwrap();
522
523        // Only Rust should appear (it has more code lines)
524        assert!(md_str.contains("| Rust |"));
525        // Count occurrences of language rows (excluding header)
526        let rust_count = md_str.matches("| Rust |").count();
527        let python_count = md_str.matches("| Python |").count();
528        assert_eq!(rust_count, 1);
529        assert_eq!(python_count, 0);
530    }
531
532    #[test]
533    fn test_markdown_output_footer() {
534        let output = MarkdownOutput;
535        let result = make_test_result();
536        let options = OutputOptions::default();
537
538        let mut buffer = Vec::new();
539        output
540            .write(&Report::Analysis(result), &options, &mut buffer)
541            .unwrap();
542
543        let md_str = String::from_utf8(buffer).unwrap();
544
545        assert!(md_str.contains("---"));
546        assert!(md_str.contains("*Generated by codelens"));
547    }
548}