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};
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        result: &AnalysisResult,
38        options: &OutputOptions,
39        writer: &mut dyn Write,
40    ) -> Result<()> {
41        let summary = &result.summary;
42
43        writeln!(writer, "# Code Statistics Report")?;
44        writeln!(writer)?;
45
46        // Summary
47        writeln!(writer, "## Summary")?;
48        writeln!(writer)?;
49        writeln!(writer, "| Metric | Value |")?;
50        writeln!(writer, "|--------|-------|")?;
51        writeln!(writer, "| Total Files | {} |", summary.total_files)?;
52        writeln!(writer, "| Code Lines | {} |", summary.lines.code)?;
53        writeln!(writer, "| Comment Lines | {} |", summary.lines.comment)?;
54        writeln!(writer, "| Blank Lines | {} |", summary.lines.blank)?;
55        writeln!(writer, "| Total Lines | {} |", summary.lines.total)?;
56        writeln!(writer, "| Languages | {} |", summary.by_language.len())?;
57        writeln!(writer)?;
58
59        // Language breakdown
60        if !options.summary_only && !summary.by_language.is_empty() {
61            writeln!(writer, "## By Language")?;
62            writeln!(writer)?;
63            writeln!(
64                writer,
65                "| Language | Files | Code | Comment | Blank | Total |"
66            )?;
67            writeln!(
68                writer,
69                "|----------|-------|------|---------|-------|-------|"
70            )?;
71
72            let mut langs: Vec<_> = summary.by_language.iter().collect();
73            if let Some(n) = options.top_n {
74                langs.truncate(n);
75            }
76
77            for (name, stats) in langs {
78                writeln!(
79                    writer,
80                    "| {} | {} | {} | {} | {} | {} |",
81                    name,
82                    stats.files,
83                    stats.lines.code,
84                    stats.lines.comment,
85                    stats.lines.blank,
86                    stats.lines.total
87                )?;
88            }
89            writeln!(writer)?;
90        }
91
92        writeln!(writer, "---")?;
93        writeln!(
94            writer,
95            "*Generated by codelens in {:.2}s*",
96            result.elapsed.as_secs_f64()
97        )?;
98
99        Ok(())
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::analyzer::stats::{FileStats, LineStats, Summary};
107    use std::path::PathBuf;
108    use std::time::Duration;
109
110    fn make_test_result() -> AnalysisResult {
111        let files = vec![
112            FileStats {
113                path: PathBuf::from("main.rs"),
114                language: "Rust".to_string(),
115                lines: LineStats {
116                    total: 100,
117                    code: 80,
118                    comment: 10,
119                    blank: 10,
120                },
121                size: 2000,
122                complexity: Default::default(),
123            },
124            FileStats {
125                path: PathBuf::from("test.py"),
126                language: "Python".to_string(),
127                lines: LineStats {
128                    total: 50,
129                    code: 40,
130                    comment: 5,
131                    blank: 5,
132                },
133                size: 1000,
134                complexity: Default::default(),
135            },
136        ];
137        AnalysisResult {
138            summary: Summary::from_file_stats(&files),
139            files,
140            elapsed: Duration::from_secs(1),
141            scanned_files: 2,
142            skipped_files: 0,
143        }
144    }
145
146    #[test]
147    fn test_markdown_output_name() {
148        let output = MarkdownOutput::new();
149        assert_eq!(output.name(), "markdown");
150        assert_eq!(output.extension(), "md");
151    }
152
153    #[test]
154    fn test_markdown_output_title() {
155        let output = MarkdownOutput;
156        let result = make_test_result();
157        let options = OutputOptions::default();
158
159        let mut buffer = Vec::new();
160        output.write(&result, &options, &mut buffer).unwrap();
161
162        let md_str = String::from_utf8(buffer).unwrap();
163        assert!(md_str.starts_with("# Code Statistics Report"));
164    }
165
166    #[test]
167    fn test_markdown_output_summary_table() {
168        let output = MarkdownOutput;
169        let result = make_test_result();
170        let options = OutputOptions::default();
171
172        let mut buffer = Vec::new();
173        output.write(&result, &options, &mut buffer).unwrap();
174
175        let md_str = String::from_utf8(buffer).unwrap();
176
177        assert!(md_str.contains("## Summary"));
178        assert!(md_str.contains("| Total Files | 2 |"));
179        assert!(md_str.contains("| Code Lines | 120 |"));
180    }
181
182    #[test]
183    fn test_markdown_output_language_breakdown() {
184        let output = MarkdownOutput;
185        let result = make_test_result();
186        let options = OutputOptions {
187            summary_only: false,
188            ..Default::default()
189        };
190
191        let mut buffer = Vec::new();
192        output.write(&result, &options, &mut buffer).unwrap();
193
194        let md_str = String::from_utf8(buffer).unwrap();
195
196        assert!(md_str.contains("## By Language"));
197        assert!(md_str.contains("| Rust |"));
198        assert!(md_str.contains("| Python |"));
199    }
200
201    #[test]
202    fn test_markdown_output_summary_only() {
203        let output = MarkdownOutput;
204        let result = make_test_result();
205        let options = OutputOptions {
206            summary_only: true,
207            ..Default::default()
208        };
209
210        let mut buffer = Vec::new();
211        output.write(&result, &options, &mut buffer).unwrap();
212
213        let md_str = String::from_utf8(buffer).unwrap();
214
215        assert!(md_str.contains("## Summary"));
216        assert!(!md_str.contains("## By Language"));
217    }
218
219    #[test]
220    fn test_markdown_output_top_n() {
221        let output = MarkdownOutput;
222        let result = make_test_result();
223        let options = OutputOptions {
224            top_n: Some(1),
225            ..Default::default()
226        };
227
228        let mut buffer = Vec::new();
229        output.write(&result, &options, &mut buffer).unwrap();
230
231        let md_str = String::from_utf8(buffer).unwrap();
232
233        // Only Rust should appear (it has more code lines)
234        assert!(md_str.contains("| Rust |"));
235        // Count occurrences of language rows (excluding header)
236        let rust_count = md_str.matches("| Rust |").count();
237        let python_count = md_str.matches("| Python |").count();
238        assert_eq!(rust_count, 1);
239        assert_eq!(python_count, 0);
240    }
241
242    #[test]
243    fn test_markdown_output_footer() {
244        let output = MarkdownOutput;
245        let result = make_test_result();
246        let options = OutputOptions::default();
247
248        let mut buffer = Vec::new();
249        output.write(&result, &options, &mut buffer).unwrap();
250
251        let md_str = String::from_utf8(buffer).unwrap();
252
253        assert!(md_str.contains("---"));
254        assert!(md_str.contains("*Generated by codelens"));
255    }
256}