use std::io::Write;
use crate::analyzer::stats::AnalysisResult;
use crate::error::Result;
use super::format::{OutputFormat, OutputOptions, Report};
pub struct MarkdownOutput;
impl MarkdownOutput {
pub fn new() -> Self {
Self
}
}
impl Default for MarkdownOutput {
fn default() -> Self {
Self::new()
}
}
impl OutputFormat for MarkdownOutput {
fn name(&self) -> &'static str {
"markdown"
}
fn extension(&self) -> &'static str {
"md"
}
fn write(
&self,
report: &Report,
options: &OutputOptions,
writer: &mut dyn Write,
) -> Result<()> {
match report {
Report::Analysis(result) => self.write_analysis(result, options, writer),
Report::Health(report) => self.write_health(report, options, writer),
Report::Hotspot(report) => self.write_hotspot(report, options, writer),
Report::Trend(report) => self.write_trend(report, options, writer),
}
}
}
impl MarkdownOutput {
fn write_analysis(
&self,
result: &AnalysisResult,
options: &OutputOptions,
writer: &mut dyn Write,
) -> Result<()> {
let summary = &result.summary;
writeln!(writer, "# Code Statistics Report")?;
writeln!(writer)?;
writeln!(writer, "## Summary")?;
writeln!(writer)?;
writeln!(writer, "| Metric | Value |")?;
writeln!(writer, "|--------|-------|")?;
writeln!(writer, "| Total Files | {} |", summary.total_files)?;
writeln!(writer, "| Code Lines | {} |", summary.lines.code)?;
writeln!(writer, "| Comment Lines | {} |", summary.lines.comment)?;
writeln!(writer, "| Blank Lines | {} |", summary.lines.blank)?;
writeln!(writer, "| Total Lines | {} |", summary.lines.total)?;
writeln!(writer, "| Languages | {} |", summary.by_language.len())?;
writeln!(writer)?;
if !options.summary_only && !summary.by_language.is_empty() {
writeln!(writer, "## By Language")?;
writeln!(writer)?;
writeln!(
writer,
"| Language | Files | Code | Comment | Blank | Total |"
)?;
writeln!(
writer,
"|----------|-------|------|---------|-------|-------|"
)?;
let mut langs: Vec<_> = summary.by_language.iter().collect();
if let Some(n) = options.top_n {
langs.truncate(n);
}
for (name, stats) in langs {
writeln!(
writer,
"| {} | {} | {} | {} | {} | {} |",
name,
stats.files,
stats.lines.code,
stats.lines.comment,
stats.lines.blank,
stats.lines.total
)?;
}
writeln!(writer)?;
}
writeln!(writer, "---")?;
writeln!(
writer,
"*Generated by codelens in {:.2}s*",
result.elapsed.as_secs_f64()
)?;
Ok(())
}
fn write_health(
&self,
report: &crate::insight::health::HealthReport,
options: &OutputOptions,
writer: &mut dyn Write,
) -> Result<()> {
writeln!(writer, "# Code Health Report")?;
writeln!(writer)?;
writeln!(
writer,
"**Project Score:** {:.1} | **Grade:** {}",
report.score, report.grade
)?;
writeln!(writer)?;
writeln!(writer, "## Dimensions")?;
writeln!(writer)?;
writeln!(writer, "| Dimension | Score | Grade |")?;
writeln!(writer, "|-----------|-------|-------|")?;
for dim in &report.dimensions {
writeln!(
writer,
"| {} | {:.1} | {} |",
dim.dimension, dim.score, dim.grade
)?;
}
writeln!(writer)?;
if !options.summary_only {
if !report.by_directory.is_empty() {
writeln!(writer, "## By Directory")?;
writeln!(writer)?;
writeln!(writer, "| Directory | Score | Grade | Files |")?;
writeln!(writer, "|-----------|-------|-------|-------|")?;
for dir in &report.by_directory {
writeln!(
writer,
"| {} | {:.1} | {} | {} |",
dir.path.display(),
dir.score,
dir.grade,
dir.file_count
)?;
}
writeln!(writer)?;
}
if !report.worst_files.is_empty() {
writeln!(writer, "## Worst Files")?;
writeln!(writer)?;
writeln!(writer, "| File | Score | Grade | Top Issue |")?;
writeln!(writer, "|------|-------|-------|-----------|")?;
for file in &report.worst_files {
writeln!(
writer,
"| {} | {:.1} | {} | {} |",
file.path.display(),
file.score,
file.grade,
file.top_issue
)?;
}
writeln!(writer)?;
}
}
Ok(())
}
fn write_hotspot(
&self,
report: &crate::insight::hotspot::HotspotReport,
_options: &OutputOptions,
writer: &mut dyn Write,
) -> Result<()> {
writeln!(writer, "# Hotspot Analysis")?;
writeln!(writer)?;
writeln!(
writer,
"**Period:** {} | **Total Commits:** {}",
report.since, report.total_commits
)?;
writeln!(writer)?;
if report.files.is_empty() {
writeln!(writer, "No hotspots found.")?;
return Ok(());
}
writeln!(writer, "| File | Chg | +/- | CC | Score | Risk |")?;
writeln!(writer, "|------|-----|-----|----|-------|------|")?;
for file in &report.files {
writeln!(
writer,
"| {} | {} | +{}/-{} | {} | {:.2} | {} |",
file.path.display(),
file.churn.commits,
file.churn.lines_added,
file.churn.lines_deleted,
file.complexity.cyclomatic,
file.hotspot_score,
file.risk,
)?;
}
writeln!(writer)?;
Ok(())
}
fn write_trend(
&self,
report: &crate::insight::trend::TrendReport,
_options: &OutputOptions,
writer: &mut dyn Write,
) -> Result<()> {
let from_label = report.from.label.as_deref().unwrap_or("");
let to_label = report.to.label.as_deref().unwrap_or("");
writeln!(writer, "# Trend Report")?;
writeln!(writer)?;
writeln!(
writer,
"**From:** {} {} | **To:** {} {}",
report.from.timestamp.format("%Y-%m-%d"),
from_label,
report.to.timestamp.format("%Y-%m-%d"),
to_label,
)?;
writeln!(writer)?;
writeln!(writer, "## Delta")?;
writeln!(writer)?;
writeln!(writer, "| Metric | Before | After | Delta | Change |")?;
writeln!(writer, "|--------|--------|-------|-------|--------|")?;
let deltas = [
("Files", &report.delta.files),
("Lines", &report.delta.lines),
("Code", &report.delta.code),
("Comments", &report.delta.comment),
("Blank", &report.delta.blank),
("Complexity", &report.delta.complexity),
("Functions", &report.delta.functions),
];
for (name, dv) in &deltas {
let signed = dv.signed_delta();
let sign = if signed > 0 { "+" } else { "" };
writeln!(
writer,
"| {} | {} | {} | {}{} | {:+.1}% |",
name, dv.from, dv.to, sign, signed, dv.percent,
)?;
}
writeln!(writer)?;
if !report.by_language.is_empty() {
writeln!(writer, "## By Language")?;
writeln!(writer)?;
writeln!(writer, "| Language | Status | Before | After | Delta |")?;
writeln!(writer, "|----------|--------|--------|-------|-------|")?;
for lang in &report.by_language {
let signed = lang.code.signed_delta();
let sign = if signed > 0 { "+" } else { "" };
writeln!(
writer,
"| {} | {} | {} | {} | {}{} |",
lang.language, lang.status, lang.code.from, lang.code.to, sign, signed,
)?;
}
writeln!(writer)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::Report;
use super::*;
use crate::analyzer::stats::{FileStats, LineStats, Summary};
use std::path::PathBuf;
use std::time::Duration;
fn make_test_result() -> AnalysisResult {
let files = vec![
FileStats {
path: PathBuf::from("main.rs"),
language: "Rust".to_string(),
lines: LineStats {
total: 100,
code: 80,
comment: 10,
blank: 10,
},
size: 2000,
complexity: Default::default(),
},
FileStats {
path: PathBuf::from("test.py"),
language: "Python".to_string(),
lines: LineStats {
total: 50,
code: 40,
comment: 5,
blank: 5,
},
size: 1000,
complexity: Default::default(),
},
];
AnalysisResult {
summary: Summary::from_file_stats(&files),
files,
elapsed: Duration::from_secs(1),
scanned_files: 2,
skipped_files: 0,
}
}
#[test]
fn test_markdown_output_name() {
let output = MarkdownOutput::new();
assert_eq!(output.name(), "markdown");
assert_eq!(output.extension(), "md");
}
#[test]
fn test_markdown_output_title() {
let output = MarkdownOutput;
let result = make_test_result();
let options = OutputOptions::default();
let mut buffer = Vec::new();
output
.write(&Report::Analysis(result), &options, &mut buffer)
.unwrap();
let md_str = String::from_utf8(buffer).unwrap();
assert!(md_str.starts_with("# Code Statistics Report"));
}
#[test]
fn test_markdown_output_summary_table() {
let output = MarkdownOutput;
let result = make_test_result();
let options = OutputOptions::default();
let mut buffer = Vec::new();
output
.write(&Report::Analysis(result), &options, &mut buffer)
.unwrap();
let md_str = String::from_utf8(buffer).unwrap();
assert!(md_str.contains("## Summary"));
assert!(md_str.contains("| Total Files | 2 |"));
assert!(md_str.contains("| Code Lines | 120 |"));
}
#[test]
fn test_markdown_output_language_breakdown() {
let output = MarkdownOutput;
let result = make_test_result();
let options = OutputOptions {
summary_only: false,
..Default::default()
};
let mut buffer = Vec::new();
output
.write(&Report::Analysis(result), &options, &mut buffer)
.unwrap();
let md_str = String::from_utf8(buffer).unwrap();
assert!(md_str.contains("## By Language"));
assert!(md_str.contains("| Rust |"));
assert!(md_str.contains("| Python |"));
}
#[test]
fn test_markdown_output_summary_only() {
let output = MarkdownOutput;
let result = make_test_result();
let options = OutputOptions {
summary_only: true,
..Default::default()
};
let mut buffer = Vec::new();
output
.write(&Report::Analysis(result), &options, &mut buffer)
.unwrap();
let md_str = String::from_utf8(buffer).unwrap();
assert!(md_str.contains("## Summary"));
assert!(!md_str.contains("## By Language"));
}
#[test]
fn test_markdown_output_top_n() {
let output = MarkdownOutput;
let result = make_test_result();
let options = OutputOptions {
top_n: Some(1),
..Default::default()
};
let mut buffer = Vec::new();
output
.write(&Report::Analysis(result), &options, &mut buffer)
.unwrap();
let md_str = String::from_utf8(buffer).unwrap();
assert!(md_str.contains("| Rust |"));
let rust_count = md_str.matches("| Rust |").count();
let python_count = md_str.matches("| Python |").count();
assert_eq!(rust_count, 1);
assert_eq!(python_count, 0);
}
#[test]
fn test_markdown_output_footer() {
let output = MarkdownOutput;
let result = make_test_result();
let options = OutputOptions::default();
let mut buffer = Vec::new();
output
.write(&Report::Analysis(result), &options, &mut buffer)
.unwrap();
let md_str = String::from_utf8(buffer).unwrap();
assert!(md_str.contains("---"));
assert!(md_str.contains("*Generated by codelens"));
}
}