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        }
47    }
48}
49
50impl MarkdownOutput {
51    fn write_analysis(
52        &self,
53        result: &AnalysisResult,
54        options: &OutputOptions,
55        writer: &mut dyn Write,
56    ) -> Result<()> {
57        let summary = &result.summary;
58
59        writeln!(writer, "# Code Statistics Report")?;
60        writeln!(writer)?;
61
62        // Summary
63        writeln!(writer, "## Summary")?;
64        writeln!(writer)?;
65        writeln!(writer, "| Metric | Value |")?;
66        writeln!(writer, "|--------|-------|")?;
67        writeln!(writer, "| Total Files | {} |", summary.total_files)?;
68        writeln!(writer, "| Code Lines | {} |", summary.lines.code)?;
69        writeln!(writer, "| Comment Lines | {} |", summary.lines.comment)?;
70        writeln!(writer, "| Blank Lines | {} |", summary.lines.blank)?;
71        writeln!(writer, "| Total Lines | {} |", summary.lines.total)?;
72        writeln!(writer, "| Languages | {} |", summary.by_language.len())?;
73        writeln!(writer)?;
74
75        // Language breakdown
76        if !options.summary_only && !summary.by_language.is_empty() {
77            writeln!(writer, "## By Language")?;
78            writeln!(writer)?;
79            writeln!(
80                writer,
81                "| Language | Files | Code | Comment | Blank | Total |"
82            )?;
83            writeln!(
84                writer,
85                "|----------|-------|------|---------|-------|-------|"
86            )?;
87
88            let mut langs: Vec<_> = summary.by_language.iter().collect();
89            if let Some(n) = options.top_n {
90                langs.truncate(n);
91            }
92
93            for (name, stats) in langs {
94                writeln!(
95                    writer,
96                    "| {} | {} | {} | {} | {} | {} |",
97                    name,
98                    stats.files,
99                    stats.lines.code,
100                    stats.lines.comment,
101                    stats.lines.blank,
102                    stats.lines.total
103                )?;
104            }
105            writeln!(writer)?;
106        }
107
108        writeln!(writer, "---")?;
109        writeln!(
110            writer,
111            "*Generated by codelens in {:.2}s*",
112            result.elapsed.as_secs_f64()
113        )?;
114
115        Ok(())
116    }
117
118    fn write_health(
119        &self,
120        report: &crate::insight::health::HealthReport,
121        options: &OutputOptions,
122        writer: &mut dyn Write,
123    ) -> Result<()> {
124        writeln!(writer, "# Code Health Report")?;
125        writeln!(writer)?;
126        writeln!(
127            writer,
128            "**Project Score:** {:.1} | **Grade:** {}",
129            report.score, report.grade
130        )?;
131        writeln!(writer)?;
132
133        // Dimensions
134        writeln!(writer, "## Dimensions")?;
135        writeln!(writer)?;
136        writeln!(writer, "| Dimension | Score | Grade |")?;
137        writeln!(writer, "|-----------|-------|-------|")?;
138        for dim in &report.dimensions {
139            writeln!(
140                writer,
141                "| {} | {:.1} | {} |",
142                dim.dimension, dim.score, dim.grade
143            )?;
144        }
145        writeln!(writer)?;
146
147        if !options.summary_only {
148            if !report.by_directory.is_empty() {
149                writeln!(writer, "## By Directory")?;
150                writeln!(writer)?;
151                writeln!(writer, "| Directory | Score | Grade | Files |")?;
152                writeln!(writer, "|-----------|-------|-------|-------|")?;
153                for dir in &report.by_directory {
154                    writeln!(
155                        writer,
156                        "| {} | {:.1} | {} | {} |",
157                        dir.path.display(),
158                        dir.score,
159                        dir.grade,
160                        dir.file_count
161                    )?;
162                }
163                writeln!(writer)?;
164            }
165
166            if !report.worst_files.is_empty() {
167                writeln!(writer, "## Worst Files")?;
168                writeln!(writer)?;
169                writeln!(writer, "| File | Score | Grade | Top Issue |")?;
170                writeln!(writer, "|------|-------|-------|-----------|")?;
171                for file in &report.worst_files {
172                    writeln!(
173                        writer,
174                        "| {} | {:.1} | {} | {} |",
175                        file.path.display(),
176                        file.score,
177                        file.grade,
178                        file.top_issue
179                    )?;
180                }
181                writeln!(writer)?;
182            }
183        }
184
185        Ok(())
186    }
187
188    fn write_hotspot(
189        &self,
190        report: &crate::insight::hotspot::HotspotReport,
191        _options: &OutputOptions,
192        writer: &mut dyn Write,
193    ) -> Result<()> {
194        writeln!(writer, "# Hotspot Analysis")?;
195        writeln!(writer)?;
196        writeln!(
197            writer,
198            "**Period:** {} | **Total Commits:** {}",
199            report.since, report.total_commits
200        )?;
201        writeln!(writer)?;
202
203        if report.files.is_empty() {
204            writeln!(writer, "No hotspots found.")?;
205            return Ok(());
206        }
207
208        writeln!(writer, "| File | Chg | +/- | CC | Score | Risk |")?;
209        writeln!(writer, "|------|-----|-----|----|-------|------|")?;
210        for file in &report.files {
211            writeln!(
212                writer,
213                "| {} | {} | +{}/-{} | {} | {:.2} | {} |",
214                file.path.display(),
215                file.churn.commits,
216                file.churn.lines_added,
217                file.churn.lines_deleted,
218                file.complexity.cyclomatic,
219                file.hotspot_score,
220                file.risk,
221            )?;
222        }
223        writeln!(writer)?;
224
225        Ok(())
226    }
227
228    fn write_trend(
229        &self,
230        report: &crate::insight::trend::TrendReport,
231        _options: &OutputOptions,
232        writer: &mut dyn Write,
233    ) -> Result<()> {
234        let from_label = report.from.label.as_deref().unwrap_or("");
235        let to_label = report.to.label.as_deref().unwrap_or("");
236
237        writeln!(writer, "# Trend Report")?;
238        writeln!(writer)?;
239        writeln!(
240            writer,
241            "**From:** {} {} | **To:** {} {}",
242            report.from.timestamp.format("%Y-%m-%d"),
243            from_label,
244            report.to.timestamp.format("%Y-%m-%d"),
245            to_label,
246        )?;
247        writeln!(writer)?;
248
249        // Delta table
250        writeln!(writer, "## Delta")?;
251        writeln!(writer)?;
252        writeln!(writer, "| Metric | Before | After | Delta | Change |")?;
253        writeln!(writer, "|--------|--------|-------|-------|--------|")?;
254        let deltas = [
255            ("Files", &report.delta.files),
256            ("Lines", &report.delta.lines),
257            ("Code", &report.delta.code),
258            ("Comments", &report.delta.comment),
259            ("Blank", &report.delta.blank),
260            ("Complexity", &report.delta.complexity),
261            ("Functions", &report.delta.functions),
262        ];
263        for (name, dv) in &deltas {
264            let signed = dv.signed_delta();
265            let sign = if signed > 0 { "+" } else { "" };
266            writeln!(
267                writer,
268                "| {} | {} | {} | {}{} | {:+.1}% |",
269                name, dv.from, dv.to, sign, signed, dv.percent,
270            )?;
271        }
272        writeln!(writer)?;
273
274        // By Language
275        if !report.by_language.is_empty() {
276            writeln!(writer, "## By Language")?;
277            writeln!(writer)?;
278            writeln!(writer, "| Language | Status | Before | After | Delta |")?;
279            writeln!(writer, "|----------|--------|--------|-------|-------|")?;
280            for lang in &report.by_language {
281                let signed = lang.code.signed_delta();
282                let sign = if signed > 0 { "+" } else { "" };
283                writeln!(
284                    writer,
285                    "| {} | {} | {} | {} | {}{} |",
286                    lang.language, lang.status, lang.code.from, lang.code.to, sign, signed,
287                )?;
288            }
289            writeln!(writer)?;
290        }
291
292        Ok(())
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::Report;
299    use super::*;
300    use crate::analyzer::stats::{FileStats, LineStats, Summary};
301    use std::path::PathBuf;
302    use std::time::Duration;
303
304    fn make_test_result() -> AnalysisResult {
305        let files = vec![
306            FileStats {
307                path: PathBuf::from("main.rs"),
308                language: "Rust".to_string(),
309                lines: LineStats {
310                    total: 100,
311                    code: 80,
312                    comment: 10,
313                    blank: 10,
314                },
315                size: 2000,
316                complexity: Default::default(),
317            },
318            FileStats {
319                path: PathBuf::from("test.py"),
320                language: "Python".to_string(),
321                lines: LineStats {
322                    total: 50,
323                    code: 40,
324                    comment: 5,
325                    blank: 5,
326                },
327                size: 1000,
328                complexity: Default::default(),
329            },
330        ];
331        AnalysisResult {
332            summary: Summary::from_file_stats(&files),
333            files,
334            elapsed: Duration::from_secs(1),
335            scanned_files: 2,
336            skipped_files: 0,
337        }
338    }
339
340    #[test]
341    fn test_markdown_output_name() {
342        let output = MarkdownOutput::new();
343        assert_eq!(output.name(), "markdown");
344        assert_eq!(output.extension(), "md");
345    }
346
347    #[test]
348    fn test_markdown_output_title() {
349        let output = MarkdownOutput;
350        let result = make_test_result();
351        let options = OutputOptions::default();
352
353        let mut buffer = Vec::new();
354        output
355            .write(&Report::Analysis(result), &options, &mut buffer)
356            .unwrap();
357
358        let md_str = String::from_utf8(buffer).unwrap();
359        assert!(md_str.starts_with("# Code Statistics Report"));
360    }
361
362    #[test]
363    fn test_markdown_output_summary_table() {
364        let output = MarkdownOutput;
365        let result = make_test_result();
366        let options = OutputOptions::default();
367
368        let mut buffer = Vec::new();
369        output
370            .write(&Report::Analysis(result), &options, &mut buffer)
371            .unwrap();
372
373        let md_str = String::from_utf8(buffer).unwrap();
374
375        assert!(md_str.contains("## Summary"));
376        assert!(md_str.contains("| Total Files | 2 |"));
377        assert!(md_str.contains("| Code Lines | 120 |"));
378    }
379
380    #[test]
381    fn test_markdown_output_language_breakdown() {
382        let output = MarkdownOutput;
383        let result = make_test_result();
384        let options = OutputOptions {
385            summary_only: false,
386            ..Default::default()
387        };
388
389        let mut buffer = Vec::new();
390        output
391            .write(&Report::Analysis(result), &options, &mut buffer)
392            .unwrap();
393
394        let md_str = String::from_utf8(buffer).unwrap();
395
396        assert!(md_str.contains("## By Language"));
397        assert!(md_str.contains("| Rust |"));
398        assert!(md_str.contains("| Python |"));
399    }
400
401    #[test]
402    fn test_markdown_output_summary_only() {
403        let output = MarkdownOutput;
404        let result = make_test_result();
405        let options = OutputOptions {
406            summary_only: true,
407            ..Default::default()
408        };
409
410        let mut buffer = Vec::new();
411        output
412            .write(&Report::Analysis(result), &options, &mut buffer)
413            .unwrap();
414
415        let md_str = String::from_utf8(buffer).unwrap();
416
417        assert!(md_str.contains("## Summary"));
418        assert!(!md_str.contains("## By Language"));
419    }
420
421    #[test]
422    fn test_markdown_output_top_n() {
423        let output = MarkdownOutput;
424        let result = make_test_result();
425        let options = OutputOptions {
426            top_n: Some(1),
427            ..Default::default()
428        };
429
430        let mut buffer = Vec::new();
431        output
432            .write(&Report::Analysis(result), &options, &mut buffer)
433            .unwrap();
434
435        let md_str = String::from_utf8(buffer).unwrap();
436
437        // Only Rust should appear (it has more code lines)
438        assert!(md_str.contains("| Rust |"));
439        // Count occurrences of language rows (excluding header)
440        let rust_count = md_str.matches("| Rust |").count();
441        let python_count = md_str.matches("| Python |").count();
442        assert_eq!(rust_count, 1);
443        assert_eq!(python_count, 0);
444    }
445
446    #[test]
447    fn test_markdown_output_footer() {
448        let output = MarkdownOutput;
449        let result = make_test_result();
450        let options = OutputOptions::default();
451
452        let mut buffer = Vec::new();
453        output
454            .write(&Report::Analysis(result), &options, &mut buffer)
455            .unwrap();
456
457        let md_str = String::from_utf8(buffer).unwrap();
458
459        assert!(md_str.contains("---"));
460        assert!(md_str.contains("*Generated by codelens"));
461    }
462}