Skip to content

Commit a2cba8c

Browse files
committed
feat(html): add full HTML templates for health, hotspot, trend reports
1 parent 61314b9 commit a2cba8c

4 files changed

Lines changed: 694 additions & 29 deletions

File tree

crates/codelens-core/src/output/html.rs

Lines changed: 235 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,115 @@ use askama::Template;
66

77
use crate::analyzer::stats::{AnalysisResult, LanguageSummary, Summary};
88
use crate::error::Result;
9+
use crate::insight::Grade;
910

1011
use super::format::{OutputFormat, OutputOptions, Report};
1112

12-
/// HTML report template.
13+
// ── Analysis (existing) ──────────────────────────────────
14+
1315
#[derive(Template)]
1416
#[template(path = "report.html")]
15-
struct HtmlReport<'a> {
17+
struct AnalysisHtmlReport<'a> {
1618
title: &'a str,
1719
generated_at: String,
1820
summary: &'a Summary,
1921
by_language: Vec<(&'a str, &'a LanguageSummary)>,
2022
elapsed_secs: f64,
2123
}
2224

23-
/// HTML output formatter.
25+
// ── Health ───────────────────────────────────────────────
26+
27+
struct HtmlDimensionScore {
28+
dimension: String,
29+
score_display: u32,
30+
grade: Grade,
31+
}
32+
33+
struct HtmlDirectoryHealth {
34+
path: String,
35+
score_display: u32,
36+
grade: Grade,
37+
file_count: usize,
38+
}
39+
40+
struct HtmlFileHealth {
41+
path: String,
42+
score_display: u32,
43+
grade: Grade,
44+
top_issue: String,
45+
}
46+
47+
#[derive(Template)]
48+
#[template(path = "health.html")]
49+
struct HealthHtmlReport {
50+
generated_at: String,
51+
model: String,
52+
grade: String,
53+
score: u32,
54+
dimensions: Vec<HtmlDimensionScore>,
55+
by_directory: Vec<HtmlDirectoryHealth>,
56+
worst_files: Vec<HtmlFileHealth>,
57+
}
58+
59+
// ── Hotspot ──────────────────────────────────────────────
60+
61+
struct HtmlFileHotspot {
62+
path: String,
63+
language: String,
64+
commits: usize,
65+
lines_added: usize,
66+
lines_deleted: usize,
67+
cyclomatic: usize,
68+
score_display: String,
69+
score_pct: u32,
70+
risk: String,
71+
}
72+
73+
#[derive(Template)]
74+
#[template(path = "hotspot.html")]
75+
struct HotspotHtmlReport {
76+
generated_at: String,
77+
since: String,
78+
total_commits: usize,
79+
files: Vec<HtmlFileHotspot>,
80+
}
81+
82+
// ── Trend ────────────────────────────────────────────────
83+
84+
struct HtmlTrendMetric {
85+
label: String,
86+
from_value: usize,
87+
to_value: usize,
88+
signed_delta: i64,
89+
delta_display: String,
90+
percent_display: String,
91+
}
92+
93+
struct HtmlLanguageTrend {
94+
language: String,
95+
status: String,
96+
code_from: usize,
97+
code_to: usize,
98+
code_delta: i64,
99+
code_delta_display: String,
100+
}
101+
102+
#[derive(Template)]
103+
#[template(path = "trend.html")]
104+
struct TrendHtmlReport {
105+
from_date: String,
106+
from_label: String,
107+
to_date: String,
108+
to_label: String,
109+
metrics: Vec<HtmlTrendMetric>,
110+
by_language: Vec<HtmlLanguageTrend>,
111+
}
112+
113+
// ── OutputFormat impl ────────────────────────────────────
114+
24115
pub struct HtmlOutput;
25116

26117
impl HtmlOutput {
27-
/// Create a new HTML output formatter.
28118
pub fn new() -> Self {
29119
Self
30120
}
@@ -53,35 +143,14 @@ impl OutputFormat for HtmlOutput {
53143
) -> Result<()> {
54144
match report {
55145
Report::Analysis(result) => self.write_analysis(result, options, writer),
56-
Report::Health(report) => self.write_json_html("Code Health Report", report, writer),
57-
Report::Hotspot(report) => self.write_json_html("Hotspot Analysis", report, writer),
58-
Report::Trend(report) => self.write_json_html("Trend Report", report, writer),
146+
Report::Health(report) => self.write_health(report, writer),
147+
Report::Hotspot(report) => self.write_hotspot(report, writer),
148+
Report::Trend(report) => self.write_trend(report, writer),
59149
}
60150
}
61151
}
62152

63153
impl HtmlOutput {
64-
fn write_json_html<T: serde::Serialize>(
65-
&self,
66-
title: &str,
67-
data: &T,
68-
writer: &mut dyn Write,
69-
) -> Result<()> {
70-
let json = serde_json::to_string_pretty(data)?;
71-
writeln!(writer, "<!DOCTYPE html>")?;
72-
writeln!(writer, "<html><head><meta charset=\"utf-8\">")?;
73-
writeln!(writer, "<title>Codelens - {title}</title>")?;
74-
writeln!(
75-
writer,
76-
"<style>body{{font-family:monospace;margin:2em;}}pre{{background:#f5f5f5;padding:1em;overflow:auto;}}</style>"
77-
)?;
78-
writeln!(writer, "</head><body>")?;
79-
writeln!(writer, "<h1>{title}</h1>")?;
80-
writeln!(writer, "<pre>{json}</pre>")?;
81-
writeln!(writer, "</body></html>")?;
82-
Ok(())
83-
}
84-
85154
fn write_analysis(
86155
&self,
87156
result: &AnalysisResult,
@@ -99,7 +168,7 @@ impl HtmlOutput {
99168
by_language.truncate(n);
100169
}
101170

102-
let report = HtmlReport {
171+
let report = AnalysisHtmlReport {
103172
title: "Codelens - Code Statistics Report",
104173
generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
105174
summary: &result.summary,
@@ -110,4 +179,141 @@ impl HtmlOutput {
110179
write!(writer, "{}", report.render()?)?;
111180
Ok(())
112181
}
182+
183+
fn write_health(
184+
&self,
185+
report: &crate::insight::health::HealthReport,
186+
writer: &mut dyn Write,
187+
) -> Result<()> {
188+
let html = HealthHtmlReport {
189+
generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
190+
model: report.model.clone(),
191+
grade: report.grade.to_string(),
192+
score: report.score as u32,
193+
dimensions: report
194+
.dimensions
195+
.iter()
196+
.map(|d| HtmlDimensionScore {
197+
dimension: d.dimension.to_string(),
198+
score_display: d.score as u32,
199+
grade: d.grade,
200+
})
201+
.collect(),
202+
by_directory: report
203+
.by_directory
204+
.iter()
205+
.map(|d| HtmlDirectoryHealth {
206+
path: d.path.display().to_string(),
207+
score_display: d.score as u32,
208+
grade: d.grade,
209+
file_count: d.file_count,
210+
})
211+
.collect(),
212+
worst_files: report
213+
.worst_files
214+
.iter()
215+
.map(|f| HtmlFileHealth {
216+
path: f.path.display().to_string(),
217+
score_display: f.score as u32,
218+
grade: f.grade,
219+
top_issue: f.top_issue.to_string(),
220+
})
221+
.collect(),
222+
};
223+
write!(writer, "{}", html.render()?)?;
224+
Ok(())
225+
}
226+
227+
fn write_hotspot(
228+
&self,
229+
report: &crate::insight::hotspot::HotspotReport,
230+
writer: &mut dyn Write,
231+
) -> Result<()> {
232+
let html = HotspotHtmlReport {
233+
generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
234+
since: report.since.clone(),
235+
total_commits: report.total_commits,
236+
files: report
237+
.files
238+
.iter()
239+
.map(|h| HtmlFileHotspot {
240+
path: h.path.display().to_string(),
241+
language: h.language.clone(),
242+
commits: h.churn.commits,
243+
lines_added: h.churn.lines_added,
244+
lines_deleted: h.churn.lines_deleted,
245+
cyclomatic: h.complexity.cyclomatic,
246+
score_display: format!("{:.2}", h.hotspot_score),
247+
score_pct: (h.hotspot_score * 100.0) as u32,
248+
risk: h.risk.to_string(),
249+
})
250+
.collect(),
251+
};
252+
write!(writer, "{}", html.render()?)?;
253+
Ok(())
254+
}
255+
256+
fn write_trend(
257+
&self,
258+
report: &crate::insight::trend::TrendReport,
259+
writer: &mut dyn Write,
260+
) -> Result<()> {
261+
let d = &report.delta;
262+
let make_metric =
263+
|label: &str, dv: &crate::insight::DeltaValue<usize>| -> HtmlTrendMetric {
264+
let signed = dv.signed_delta();
265+
HtmlTrendMetric {
266+
label: label.to_string(),
267+
from_value: dv.from,
268+
to_value: dv.to,
269+
signed_delta: signed,
270+
delta_display: if signed >= 0 {
271+
format!("+{signed}")
272+
} else {
273+
format!("{signed}")
274+
},
275+
percent_display: format!("{:+.1}%", dv.percent),
276+
}
277+
};
278+
279+
let metrics = vec![
280+
make_metric("Files", &d.files),
281+
make_metric("Code", &d.code),
282+
make_metric("Comments", &d.comment),
283+
make_metric("Blank", &d.blank),
284+
make_metric("Complexity", &d.complexity),
285+
make_metric("Functions", &d.functions),
286+
];
287+
288+
let by_language: Vec<HtmlLanguageTrend> = report
289+
.by_language
290+
.iter()
291+
.map(|lt| {
292+
let signed = lt.code.signed_delta();
293+
HtmlLanguageTrend {
294+
language: lt.language.clone(),
295+
status: lt.status.to_string(),
296+
code_from: lt.code.from,
297+
code_to: lt.code.to,
298+
code_delta: signed,
299+
code_delta_display: if signed >= 0 {
300+
format!("+{signed}")
301+
} else {
302+
format!("{signed}")
303+
},
304+
}
305+
})
306+
.collect();
307+
308+
let html = TrendHtmlReport {
309+
from_date: report.from.timestamp.format("%Y-%m-%d").to_string(),
310+
from_label: report.from.label.as_deref().unwrap_or("").to_string(),
311+
to_date: report.to.timestamp.format("%Y-%m-%d").to_string(),
312+
to_label: report.to.label.as_deref().unwrap_or("").to_string(),
313+
metrics,
314+
by_language,
315+
};
316+
write!(writer, "{}", html.render()?)?;
317+
Ok(())
318+
}
113319
}

0 commit comments

Comments
 (0)