1use std::io::Write;
4
5use crate::analyzer::stats::AnalysisResult;
6use crate::error::Result;
7
8use super::format::{OutputFormat, OutputOptions, Report};
9
10pub struct MarkdownOutput;
12
13impl MarkdownOutput {
14 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 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 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 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 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 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 assert!(md_str.contains("| Rust |"));
439 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}