1use std::io::Write;
4
5use askama::Template;
6
7use crate::analyzer::stats::{AnalysisResult, LanguageSummary, Summary};
8use crate::error::Result;
9use crate::insight::Grade;
10
11use super::format::{OutputFormat, OutputOptions, Report};
12
13#[derive(Template)]
16#[template(path = "report.html")]
17struct AnalysisHtmlReport<'a> {
18 title: &'a str,
19 generated_at: String,
20 summary: &'a Summary,
21 by_language: Vec<(&'a str, &'a LanguageSummary)>,
22 elapsed_secs: f64,
23}
24
25struct HtmlDimensionScore {
28 dimension: String,
29 score_display: u32,
30 grade: Grade,
31}
32
33struct HtmlDirectoryHealth {
34 path: String,
35 score_display: u32,
36 grade: Grade,
37 file_count: usize,
38}
39
40struct 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")]
49struct 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
59struct 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")]
75struct HotspotHtmlReport {
76 generated_at: String,
77 since: String,
78 total_commits: usize,
79 files: Vec<HtmlFileHotspot>,
80}
81
82struct 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
93struct 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")]
104struct 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
113pub struct HtmlOutput;
116
117impl HtmlOutput {
118 pub fn new() -> Self {
119 Self
120 }
121}
122
123impl Default for HtmlOutput {
124 fn default() -> Self {
125 Self::new()
126 }
127}
128
129impl OutputFormat for HtmlOutput {
130 fn name(&self) -> &'static str {
131 "html"
132 }
133
134 fn extension(&self) -> &'static str {
135 "html"
136 }
137
138 fn write(
139 &self,
140 report: &Report,
141 options: &OutputOptions,
142 writer: &mut dyn Write,
143 ) -> Result<()> {
144 match report {
145 Report::Analysis(result) => self.write_analysis(result, options, 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),
149 }
150 }
151}
152
153impl HtmlOutput {
154 fn write_analysis(
155 &self,
156 result: &AnalysisResult,
157 options: &OutputOptions,
158 writer: &mut dyn Write,
159 ) -> Result<()> {
160 let mut by_language: Vec<_> = result
161 .summary
162 .by_language
163 .iter()
164 .map(|(k, v)| (k.as_str(), v))
165 .collect();
166
167 if let Some(n) = options.top_n {
168 by_language.truncate(n);
169 }
170
171 let report = AnalysisHtmlReport {
172 title: "Codelens - Code Statistics Report",
173 generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
174 summary: &result.summary,
175 by_language,
176 elapsed_secs: result.elapsed.as_secs_f64(),
177 };
178
179 write!(writer, "{}", report.render()?)?;
180 Ok(())
181 }
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 }
319}