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 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 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 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 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 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 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 assert!(md_str.contains("| Rust |"));
525 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}