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
113struct HtmlLanguageEstimation {
116 language: String,
117 code_lines: usize,
118 effort_months: String,
119 cost: String,
120 cost_raw: f64,
122 effort_raw: f64,
123}
124
125struct HtmlEstimationParam {
126 key: String,
127 value: String,
128}
129
130#[derive(Template)]
131#[template(path = "estimation.html")]
132struct EstimationHtmlReport {
133 generated_at: String,
134 model: String,
135 total_sloc: usize,
136 estimated_cost: String,
137 schedule_months: String,
138 people_required: String,
139 by_language: Vec<HtmlLanguageEstimation>,
140 params: Vec<HtmlEstimationParam>,
141}
142
143struct HtmlComparisonRow {
146 model: String,
147 effort_months: String,
148 schedule_months: String,
149 people_required: String,
150 estimated_cost: String,
151 cost_raw: f64,
152}
153
154#[derive(Template)]
155#[template(path = "estimation_comparison.html")]
156struct EstimationComparisonHtmlReport {
157 generated_at: String,
158 total_sloc: usize,
159 rows: Vec<HtmlComparisonRow>,
160}
161
162pub struct HtmlOutput;
165
166impl HtmlOutput {
167 pub fn new() -> Self {
168 Self
169 }
170}
171
172impl Default for HtmlOutput {
173 fn default() -> Self {
174 Self::new()
175 }
176}
177
178impl OutputFormat for HtmlOutput {
179 fn name(&self) -> &'static str {
180 "html"
181 }
182
183 fn extension(&self) -> &'static str {
184 "html"
185 }
186
187 fn write(
188 &self,
189 report: &Report,
190 options: &OutputOptions,
191 writer: &mut dyn Write,
192 ) -> Result<()> {
193 match report {
194 Report::Analysis(result) => self.write_analysis(result, options, writer),
195 Report::Health(report) => self.write_health(report, writer),
196 Report::Hotspot(report) => self.write_hotspot(report, writer),
197 Report::Trend(report) => self.write_trend(report, writer),
198 Report::Estimation(report) => self.write_estimation(report, writer),
199 Report::EstimationComparison(report) => {
200 self.write_estimation_comparison(report, writer)
201 }
202 }
203 }
204}
205
206impl HtmlOutput {
207 fn write_analysis(
208 &self,
209 result: &AnalysisResult,
210 options: &OutputOptions,
211 writer: &mut dyn Write,
212 ) -> Result<()> {
213 let mut by_language: Vec<_> = result
214 .summary
215 .by_language
216 .iter()
217 .map(|(k, v)| (k.as_str(), v))
218 .collect();
219
220 if let Some(n) = options.top_n {
221 by_language.truncate(n);
222 }
223
224 let report = AnalysisHtmlReport {
225 title: "Codelens - Code Statistics Report",
226 generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
227 summary: &result.summary,
228 by_language,
229 elapsed_secs: result.elapsed.as_secs_f64(),
230 };
231
232 write!(writer, "{}", report.render()?)?;
233 Ok(())
234 }
235
236 fn write_health(
237 &self,
238 report: &crate::insight::health::HealthReport,
239 writer: &mut dyn Write,
240 ) -> Result<()> {
241 let html = HealthHtmlReport {
242 generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
243 model: report.model.clone(),
244 grade: report.grade.to_string(),
245 score: report.score as u32,
246 dimensions: report
247 .dimensions
248 .iter()
249 .map(|d| HtmlDimensionScore {
250 dimension: d.dimension.to_string(),
251 score_display: d.score as u32,
252 grade: d.grade,
253 })
254 .collect(),
255 by_directory: report
256 .by_directory
257 .iter()
258 .map(|d| HtmlDirectoryHealth {
259 path: d.path.display().to_string(),
260 score_display: d.score as u32,
261 grade: d.grade,
262 file_count: d.file_count,
263 })
264 .collect(),
265 worst_files: report
266 .worst_files
267 .iter()
268 .map(|f| HtmlFileHealth {
269 path: f.path.display().to_string(),
270 score_display: f.score as u32,
271 grade: f.grade,
272 top_issue: f.top_issue.to_string(),
273 })
274 .collect(),
275 };
276 write!(writer, "{}", html.render()?)?;
277 Ok(())
278 }
279
280 fn write_hotspot(
281 &self,
282 report: &crate::insight::hotspot::HotspotReport,
283 writer: &mut dyn Write,
284 ) -> Result<()> {
285 let html = HotspotHtmlReport {
286 generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
287 since: report.since.clone(),
288 total_commits: report.total_commits,
289 files: report
290 .files
291 .iter()
292 .map(|h| HtmlFileHotspot {
293 path: h.path.display().to_string(),
294 language: h.language.clone(),
295 commits: h.churn.commits,
296 lines_added: h.churn.lines_added,
297 lines_deleted: h.churn.lines_deleted,
298 cyclomatic: h.complexity.cyclomatic,
299 score_display: format!("{:.2}", h.hotspot_score),
300 score_pct: (h.hotspot_score * 100.0) as u32,
301 risk: h.risk.to_string(),
302 })
303 .collect(),
304 };
305 write!(writer, "{}", html.render()?)?;
306 Ok(())
307 }
308
309 fn write_trend(
310 &self,
311 report: &crate::insight::trend::TrendReport,
312 writer: &mut dyn Write,
313 ) -> Result<()> {
314 let d = &report.delta;
315 let make_metric =
316 |label: &str, dv: &crate::insight::DeltaValue<usize>| -> HtmlTrendMetric {
317 let signed = dv.signed_delta();
318 HtmlTrendMetric {
319 label: label.to_string(),
320 from_value: dv.from,
321 to_value: dv.to,
322 signed_delta: signed,
323 delta_display: if signed >= 0 {
324 format!("+{signed}")
325 } else {
326 format!("{signed}")
327 },
328 percent_display: format!("{:+.1}%", dv.percent),
329 }
330 };
331
332 let metrics = vec![
333 make_metric("Files", &d.files),
334 make_metric("Code", &d.code),
335 make_metric("Comments", &d.comment),
336 make_metric("Blank", &d.blank),
337 make_metric("Complexity", &d.complexity),
338 make_metric("Functions", &d.functions),
339 ];
340
341 let by_language: Vec<HtmlLanguageTrend> = report
342 .by_language
343 .iter()
344 .map(|lt| {
345 let signed = lt.code.signed_delta();
346 HtmlLanguageTrend {
347 language: lt.language.clone(),
348 status: lt.status.to_string(),
349 code_from: lt.code.from,
350 code_to: lt.code.to,
351 code_delta: signed,
352 code_delta_display: if signed >= 0 {
353 format!("+{signed}")
354 } else {
355 format!("{signed}")
356 },
357 }
358 })
359 .collect();
360
361 let html = TrendHtmlReport {
362 from_date: report.from.timestamp.format("%Y-%m-%d").to_string(),
363 from_label: report.from.label.as_deref().unwrap_or("").to_string(),
364 to_date: report.to.timestamp.format("%Y-%m-%d").to_string(),
365 to_label: report.to.label.as_deref().unwrap_or("").to_string(),
366 metrics,
367 by_language,
368 };
369 write!(writer, "{}", html.render()?)?;
370 Ok(())
371 }
372
373 fn write_estimation(
374 &self,
375 report: &crate::insight::estimation::EstimationReport,
376 writer: &mut dyn Write,
377 ) -> Result<()> {
378 let html = EstimationHtmlReport {
379 generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
380 model: report.model.clone(),
381 total_sloc: report.total_sloc,
382 estimated_cost: format!("{:.2}", report.estimated_cost),
383 schedule_months: format!("{:.2}", report.schedule_months),
384 people_required: format!("{:.2}", report.people_required),
385 by_language: report
386 .by_language
387 .iter()
388 .map(|l| HtmlLanguageEstimation {
389 language: l.language.clone(),
390 code_lines: l.code_lines,
391 effort_months: format!("{:.2}", l.effort_months),
392 cost: format!("{:.2}", l.cost),
393 cost_raw: l.cost,
394 effort_raw: l.effort_months,
395 })
396 .collect(),
397 params: report
398 .params
399 .iter()
400 .map(|(k, v)| HtmlEstimationParam {
401 key: k.clone(),
402 value: v.clone(),
403 })
404 .collect(),
405 };
406 write!(writer, "{}", html.render()?)?;
407 Ok(())
408 }
409
410 fn write_estimation_comparison(
411 &self,
412 report: &crate::insight::estimation::EstimationComparison,
413 writer: &mut dyn Write,
414 ) -> Result<()> {
415 let html = EstimationComparisonHtmlReport {
416 generated_at: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
417 total_sloc: report.total_sloc,
418 rows: report
419 .reports
420 .iter()
421 .map(|r| HtmlComparisonRow {
422 model: r.model.clone(),
423 effort_months: format!("{:.2}", r.effort_months),
424 schedule_months: format!("{:.2}", r.schedule_months),
425 people_required: format!("{:.2}", r.people_required),
426 estimated_cost: format!("{:.2}", r.estimated_cost),
427 cost_raw: r.estimated_cost,
428 })
429 .collect(),
430 };
431 write!(writer, "{}", html.render()?)?;
432 Ok(())
433 }
434}